Squash changes from failed Git experiment
authorLanius Trolling <lanius@laniustrolling.dev>
Sat, 13 Apr 2024 15:25:46 +0000 (11:25 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sat, 13 Apr 2024 15:25:46 +0000 (11:25 -0400)
95 files changed:
.gitattributes [deleted file]
libs/nsapi4j.jar
src/jvmMain/kotlin/info/mechyrdia/Configuration.kt
src/jvmMain/kotlin/info/mechyrdia/auth/SessionStorage.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/auth/session_storage.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/data/DataFiles.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/data/MigrateFiles.kt
src/jvmMain/kotlin/info/mechyrdia/data/MigrateFilesSerial.kt
src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/data/data.kt
src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/data/data_flow.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/data/data_utils.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/April1st.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/FileData.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserBuilder.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserJson.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserLexer.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserLexerAsync.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPlain.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocess.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessMath.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserRaw.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserTree.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserUtils.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewMap.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewOg.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsError.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/article_titles.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/asset_caching.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_builder.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_html.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer_async.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_tree.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_utils.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/view_bar.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/view_map.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/view_og.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/views_robots.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/ResourceHandler.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/ResourceMultipart.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/route/resource_multipart.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt [deleted file]

diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644 (file)
index 841b1e0..0000000
+++ /dev/null
@@ -1 +0,0 @@
-*.pdn binary
index 4d7b198d75f25fc19bd5609a20476322f9c4a590..a80a0c13c6987c49d7c142a8b7131434a467892c 100644 (file)
Binary files a/libs/nsapi4j.jar and b/libs/nsapi4j.jar differ
index 57d37360a445e186ee6f51543c8deb8b12acf09c..17ff8fd54c786b66aba47f14f6121508af06639e 100644 (file)
@@ -33,7 +33,7 @@ data class Configuration(
 ) {
        companion object {
                val Current: Configuration by lazy {
-                       val file = File(System.getProperty("factbooks.configpath", "./config.json"))
+                       val file = File(System.getProperty("info.mechyrdia.configpath", "./config.json"))
                        if (!file.isFile) {
                                if (file.exists())
                                        file.deleteRecursively()
diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/SessionStorage.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/SessionStorage.kt
new file mode 100644 (file)
index 0000000..c8645d1
--- /dev/null
@@ -0,0 +1,33 @@
+package info.mechyrdia.auth
+
+import info.mechyrdia.data.*
+import io.ktor.server.sessions.*
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+object SessionStorageMongoDB : SessionStorage {
+       override suspend fun invalidate(id: String) {
+               SessionStorageDoc.Table.del(Id(id))
+       }
+       
+       override suspend fun read(id: String): String {
+               return SessionStorageDoc.Table.get(Id(id))?.session ?: throw NoSuchElementException("Session $id not found")
+       }
+       
+       override suspend fun write(id: String, value: String) {
+               SessionStorageDoc.Table.put(SessionStorageDoc(Id(id), value))
+       }
+}
+
+@Serializable
+data class SessionStorageDoc(
+       @SerialName(MONGODB_ID_KEY)
+       override val id: Id<SessionStorageDoc>,
+       val session: String
+) : DataDocument<SessionStorageDoc> {
+       companion object : TableHolder<SessionStorageDoc> {
+               override val Table = DocumentTable<SessionStorageDoc>()
+               
+               override suspend fun initialize() = Unit
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt
new file mode 100644 (file)
index 0000000..e348714
--- /dev/null
@@ -0,0 +1,101 @@
+package info.mechyrdia.auth
+
+import com.github.agadar.nationstates.shard.NationShard
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
+import info.mechyrdia.lore.page
+import info.mechyrdia.lore.redirectHref
+import info.mechyrdia.lore.standardNavBar
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.sessions.*
+import io.ktor.util.*
+import kotlinx.html.*
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.collections.set
+
+val PageDoNotCacheAttributeKey = AttributeKey<Boolean>("Mechyrdia.PageDoNotCache")
+
+private val nsTokenMap = ConcurrentHashMap<String, String>()
+
+suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
+       attributes.put(PageDoNotCacheAttributeKey, true)
+       
+       return page("Log In With NationStates", standardNavBar()) {
+               val tokenKey = token()
+               val nsToken = token()
+               
+               nsTokenMap[tokenKey] = nsToken
+               
+               section {
+                       h1 { +"Log In With NationStates" }
+                       form(method = FormMethod.post, action = href(Root.Auth.LoginPost())) {
+                               installCsrfToken()
+                               
+                               hiddenInput {
+                                       name = "token"
+                                       value = tokenKey
+                               }
+                               
+                               label {
+                                       +"Nation Name"
+                                       br
+                                       textInput {
+                                               name = "nation"
+                                               placeholder = "Name of your nation without pretitle, e.g. Mechyrdia, Valentine Z, Reinkalistan, etc."
+                                       }
+                               }
+                               p {
+                                       style = "text-align:center"
+                                       button(classes = "view-checksum") {
+                                               attributes["data-token"] = "mechyrdia_$nsToken"
+                                               +"View Your Checksum"
+                                       }
+                               }
+                               label {
+                                       +"Verification Checksum"
+                                       br
+                                       textInput {
+                                               name = "checksum"
+                                               placeholder = "The random text checksum generated by NationStates for verification"
+                                       }
+                               }
+                               submitInput { value = "Log In" }
+                       }
+               }
+       }
+}
+
+suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, token: String): Nothing {
+       val nationId = nation.toNationId()
+       val nsToken = nsTokenMap.remove(token)
+               ?: throw MissingRequestParameterException("token")
+       
+       val result = NSAPI
+               .verifyAndGetNation(nationId, checksum)
+               .token("mechyrdia_$nsToken")
+               .shards(NationShard.NAME, NationShard.FLAG_URL)
+               .executeSuspend()
+               ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "That nation does not exist."))))
+       
+       if (!result.isVerified)
+               redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "Checksum failed verification."))))
+       
+       val nationData = NationData(Id(result.id), result.name, result.flagUrl)
+       NationData.Table.put(nationData)
+       
+       sessions.set(UserSession(nationData.id))
+       
+       redirectHref(Root.User())
+}
+
+suspend fun ApplicationCall.logoutRoute(): Nothing {
+       val sessId = sessionId<UserSession>()
+       sessions.clear<UserSession>()
+       sessId?.let { id -> SessionStorageDoc.Table.del(Id(id)) }
+       
+       redirectHref(Root())
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/session_storage.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/session_storage.kt
deleted file mode 100644 (file)
index c8645d1..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-package info.mechyrdia.auth
-
-import info.mechyrdia.data.*
-import io.ktor.server.sessions.*
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-object SessionStorageMongoDB : SessionStorage {
-       override suspend fun invalidate(id: String) {
-               SessionStorageDoc.Table.del(Id(id))
-       }
-       
-       override suspend fun read(id: String): String {
-               return SessionStorageDoc.Table.get(Id(id))?.session ?: throw NoSuchElementException("Session $id not found")
-       }
-       
-       override suspend fun write(id: String, value: String) {
-               SessionStorageDoc.Table.put(SessionStorageDoc(Id(id), value))
-       }
-}
-
-@Serializable
-data class SessionStorageDoc(
-       @SerialName(MONGODB_ID_KEY)
-       override val id: Id<SessionStorageDoc>,
-       val session: String
-) : DataDocument<SessionStorageDoc> {
-       companion object : TableHolder<SessionStorageDoc> {
-               override val Table = DocumentTable<SessionStorageDoc>()
-               
-               override suspend fun initialize() = Unit
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/views_login.kt
deleted file mode 100644 (file)
index e348714..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-package info.mechyrdia.auth
-
-import com.github.agadar.nationstates.shard.NationShard
-import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
-import info.mechyrdia.lore.page
-import info.mechyrdia.lore.redirectHref
-import info.mechyrdia.lore.standardNavBar
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.href
-import info.mechyrdia.route.installCsrfToken
-import io.ktor.server.application.*
-import io.ktor.server.plugins.*
-import io.ktor.server.sessions.*
-import io.ktor.util.*
-import kotlinx.html.*
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.collections.set
-
-val PageDoNotCacheAttributeKey = AttributeKey<Boolean>("Mechyrdia.PageDoNotCache")
-
-private val nsTokenMap = ConcurrentHashMap<String, String>()
-
-suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
-       attributes.put(PageDoNotCacheAttributeKey, true)
-       
-       return page("Log In With NationStates", standardNavBar()) {
-               val tokenKey = token()
-               val nsToken = token()
-               
-               nsTokenMap[tokenKey] = nsToken
-               
-               section {
-                       h1 { +"Log In With NationStates" }
-                       form(method = FormMethod.post, action = href(Root.Auth.LoginPost())) {
-                               installCsrfToken()
-                               
-                               hiddenInput {
-                                       name = "token"
-                                       value = tokenKey
-                               }
-                               
-                               label {
-                                       +"Nation Name"
-                                       br
-                                       textInput {
-                                               name = "nation"
-                                               placeholder = "Name of your nation without pretitle, e.g. Mechyrdia, Valentine Z, Reinkalistan, etc."
-                                       }
-                               }
-                               p {
-                                       style = "text-align:center"
-                                       button(classes = "view-checksum") {
-                                               attributes["data-token"] = "mechyrdia_$nsToken"
-                                               +"View Your Checksum"
-                                       }
-                               }
-                               label {
-                                       +"Verification Checksum"
-                                       br
-                                       textInput {
-                                               name = "checksum"
-                                               placeholder = "The random text checksum generated by NationStates for verification"
-                                       }
-                               }
-                               submitInput { value = "Log In" }
-                       }
-               }
-       }
-}
-
-suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, token: String): Nothing {
-       val nationId = nation.toNationId()
-       val nsToken = nsTokenMap.remove(token)
-               ?: throw MissingRequestParameterException("token")
-       
-       val result = NSAPI
-               .verifyAndGetNation(nationId, checksum)
-               .token("mechyrdia_$nsToken")
-               .shards(NationShard.NAME, NationShard.FLAG_URL)
-               .executeSuspend()
-               ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "That nation does not exist."))))
-       
-       if (!result.isVerified)
-               redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "Checksum failed verification."))))
-       
-       val nationData = NationData(Id(result.id), result.name, result.flagUrl)
-       NationData.Table.put(nationData)
-       
-       sessions.set(UserSession(nationData.id))
-       
-       redirectHref(Root.User())
-}
-
-suspend fun ApplicationCall.logoutRoute(): Nothing {
-       val sessId = sessionId<UserSession>()
-       sessions.clear<UserSession>()
-       sessId?.let { id -> SessionStorageDoc.Table.del(Id(id)) }
-       
-       redirectHref(Root())
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/DataFiles.kt b/src/jvmMain/kotlin/info/mechyrdia/data/DataFiles.kt
new file mode 100644 (file)
index 0000000..7e4c5e0
--- /dev/null
@@ -0,0 +1,452 @@
+package info.mechyrdia.data
+
+import com.mongodb.client.model.Filters
+import com.mongodb.client.model.Updates
+import com.mongodb.reactivestreams.client.gridfs.GridFSBucket
+import info.mechyrdia.Configuration
+import info.mechyrdia.FileStorageConfig
+import info.mechyrdia.lore.StoragePathAttributeKey
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.response.*
+import io.ktor.util.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.reactive.asFlow
+import kotlinx.coroutines.reactive.asPublisher
+import kotlinx.coroutines.reactive.awaitFirst
+import kotlinx.coroutines.reactive.awaitFirstOrNull
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import org.bson.types.ObjectId
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.nio.ByteBuffer
+import java.nio.file.FileAlreadyExistsException
+import java.time.Instant
+import kotlin.String
+import kotlin.time.Duration.Companion.hours
+
+suspend fun ApplicationCall.respondStoredFile(path: StoragePath) {
+       val content = FileStorage.instance.readFile(path) ?: return respond(HttpStatusCode.NotFound)
+       val extension = path.elements.last().substringAfter('.', "")
+       val type = if (extension.isEmpty()) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension)
+       
+       attributes.put(StoragePathAttributeKey, path)
+       respondBytes(content, type)
+}
+
+@JvmInline
+value class StoragePath(val elements: List<String>) {
+       init {
+               for ((i, element) in elements.withIndex())
+                       require(element.any { it != '.' }) {
+                               "Cannot have elements . or .. in path, got $element at index $i in path /${elements.joinToString(separator = "/")}"
+                       }
+       }
+       
+       constructor(path: String) : this(path.split('/').filterNot(String::isEmpty))
+       
+       val name: String
+               get() = elements.lastOrNull().orEmpty()
+       
+       val isRoot: Boolean
+               get() = elements.isEmpty()
+       
+       operator fun div(element: String) = this / element.split('/')
+       operator fun div(elementCollection: Iterable<String>) = StoragePath(elements + elementCollection.filterNot(String::isEmpty))
+       
+       operator fun contains(path: StoragePath) = elements.mapIndexed { i, element ->
+               path.elements.getOrNull(i) == element
+       }.all { it }
+       
+       override fun toString(): String {
+               return elements.joinToString(separator = "/")
+       }
+       
+       companion object {
+               val Root = StoragePath(emptyList())
+               
+               val articleDir = Root / "lore"
+               val assetDir = Root / "assets"
+               val templateDir = Root / "tpl"
+               val jsonDocDir = Root / "data"
+               val scriptDir = Root / "funcs"
+               val april1Dir = Root / "funny"
+       }
+}
+
+enum class StoredFileType {
+       DIRECTORY,
+       FILE,
+}
+
+data class StoredFileStats(
+       val updated: Instant,
+       val size: Long,
+)
+
+interface FileStorage {
+       suspend fun prepare() = Unit
+       
+       suspend fun getType(path: StoragePath): StoredFileType?
+       
+       suspend fun createDir(dir: StoragePath): Boolean
+       
+       suspend fun listDir(dir: StoragePath): Map<String, StoredFileType>?
+       
+       suspend fun deleteDir(dir: StoragePath): Boolean
+       
+       suspend fun statFile(path: StoragePath): StoredFileStats?
+       
+       suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean
+       
+       suspend fun readFile(path: StoragePath): ByteArray?
+       
+       suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean
+       
+       suspend fun eraseFile(path: StoragePath): Boolean
+       
+       suspend fun performMaintenance() = Unit
+       
+       companion object {
+               lateinit var instance: FileStorage
+                       private set
+               
+               private val maintenanceScope = CoroutineScope(SupervisorJob() + CoroutineName("file-storage-maintenance"))
+               
+               suspend operator fun invoke(config: FileStorageConfig) = when (config) {
+                       is FileStorageConfig.Flat -> FlatFileStorage(File(config.baseDir))
+                       FileStorageConfig.GridFs -> GridFsStorage(
+                               DocumentTable(),
+                               ConnectionHolder.getBucket()
+                       )
+               }.apply { prepare() }
+               
+               private suspend fun configure() {
+                       instance = when (val storage = Configuration.Current.storage) {
+                               is FileStorageConfig.Flat -> FlatFileStorage(File(storage.baseDir))
+                               FileStorageConfig.GridFs -> GridFsStorage(
+                                       DocumentTable(),
+                                       ConnectionHolder.getBucket()
+                               )
+                       }.apply { prepare() }
+                       
+                       maintenanceScope.launch {
+                               while (true) {
+                                       launch(SupervisorJob(currentCoroutineContext().job)) {
+                                               instance.performMaintenance()
+                                       }
+                                       
+                                       delay(8.hours)
+                               }
+                       }
+               }
+               
+               fun initialize() = runBlocking { configure() }
+       }
+}
+
+private class FlatFileStorage(val root: File) : FileStorage {
+       private fun resolveFile(path: StoragePath) = if (path.isRoot) root else root.combineSafe(path.toString())
+       
+       private fun renderEntry(file: File) = file.name to (if (file.isFile) StoredFileType.FILE else StoredFileType.DIRECTORY)
+       
+       private fun createDir(file: File): Boolean {
+               if (file.isFile) return false
+               if (file.isDirectory) return true
+               
+               if (!file.parentFile.exists())
+                       if (!createDir(file.parentFile))
+                               return false
+               
+               file.mkdir()
+               return true
+       }
+       
+       private fun createFile(file: File): Boolean {
+               if (!file.exists()) {
+                       val containingDir = file.parentFile
+                       if (!containingDir.isDirectory)
+                               if (!createDir(containingDir)) return false
+               }
+               
+               return true
+       }
+       
+       override suspend fun prepare() {
+               withContext(Dispatchers.IO) {
+                       if (root.exists() && !root.isDirectory)
+                               root.delete()
+                       root.mkdirs()
+               }
+       }
+       
+       override suspend fun getType(path: StoragePath): StoredFileType? {
+               val file = resolveFile(path)
+               return if (file.isFile)
+                       StoredFileType.FILE
+               else if (file.isDirectory)
+                       StoredFileType.DIRECTORY
+               else null
+       }
+       
+       override suspend fun createDir(dir: StoragePath): Boolean {
+               return withContext(Dispatchers.IO) { createDir(resolveFile(dir)) }
+       }
+       
+       override suspend fun listDir(dir: StoragePath): Map<String, StoredFileType>? {
+               return withContext(Dispatchers.IO) { resolveFile(dir).listFiles()?.associate { renderEntry(it) } }
+       }
+       
+       override suspend fun deleteDir(dir: StoragePath): Boolean {
+               if (dir.isRoot) return false
+               val file = resolveFile(dir)
+               if (!file.isDirectory) return true
+               return withContext(Dispatchers.IO) { file.deleteRecursively() }
+       }
+       
+       override suspend fun statFile(path: StoragePath): StoredFileStats? {
+               val file = resolveFile(path)
+               if (!file.isFile) return null
+               
+               return StoredFileStats(Instant.ofEpochMilli(file.lastModified()), file.length())
+       }
+       
+       override suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean {
+               val file = resolveFile(path)
+               
+               return withContext(Dispatchers.IO) {
+                       if (createFile(file)) {
+                               file.writeBytes(content)
+                               true
+                       } else false
+               }
+       }
+       
+       override suspend fun readFile(path: StoragePath): ByteArray? {
+               val file = resolveFile(path)
+               if (!file.isFile) return null
+               
+               return withContext(Dispatchers.IO) {
+                       file.readBytes()
+               }
+       }
+       
+       override suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean {
+               val sourceFile = resolveFile(source)
+               val targetFile = resolveFile(target)
+               
+               if (!sourceFile.isFile) return false
+               
+               withContext(Dispatchers.IO) {
+                       sourceFile.copyTo(targetFile, overwrite = true)
+               }
+               
+               return true
+       }
+       
+       override suspend fun eraseFile(path: StoragePath): Boolean {
+               val file = resolveFile(path)
+               if (!file.isFile) return true
+               return withContext(Dispatchers.IO) { file.delete() }
+       }
+}
+
+@Serializable
+private data class GridFsEntry(
+       @SerialName(MONGODB_ID_KEY)
+       override val id: Id<GridFsEntry>,
+       val path: String,
+       val file: @Serializable(with = ObjectIdSerializer::class) ObjectId,
+       val created: @Serializable(with = InstantSerializer::class) Instant,
+       val updated: @Serializable(with = InstantSerializer::class) Instant,
+) : DataDocument<GridFsEntry>
+
+private class GridFsStorage(val table: DocumentTable<GridFsEntry>, val bucket: GridFSBucket) : FileStorage {
+       private fun toExactPath(path: StoragePath) = path.elements.joinToString(separator = "") { "/$it" }
+       private fun toPrefixPath(path: StoragePath) = "${toExactPath(path)}/"
+       
+       private suspend fun testExact(path: StoragePath) = table.number(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) > 0L
+       private suspend fun getExact(path: StoragePath) = table.locate(Filters.eq(GridFsEntry::path.serialName, toExactPath(path)))
+       private suspend fun deleteExact(path: StoragePath) = table.remove(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) > 0L
+       private suspend fun updateExact(path: StoragePath, newFile: ObjectId) {
+               val now = Instant.now()
+               val exactPath = toExactPath(path)
+               
+               table.change(
+                       Filters.eq(GridFsEntry::path.serialName, exactPath),
+                       Updates.combine(
+                               Updates.set(GridFsEntry::file.serialName, newFile),
+                               Updates.set(GridFsEntry::updated.serialName, now),
+                               Updates.setOnInsert(GridFsEntry::created.serialName, now),
+                               Updates.setOnInsert(GridFsEntry::id.serialName, Id<GridFsEntry>())
+                       )
+               )
+       }
+       
+       private suspend fun countPrefix(path: StoragePath) = table.number(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}"))
+       private suspend fun getPrefix(path: StoragePath) = table.filter(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}"))
+       private suspend fun deletePrefix(path: StoragePath) = table.remove(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}"))
+       private suspend fun createPrefix(path: StoragePath) {
+               val now = Instant.now()
+               val keepPath = path / GRID_FS_KEEP
+               
+               table.change(
+                       Filters.eq(GridFsEntry::path.serialName, toExactPath(keepPath)),
+                       Updates.combine(
+                               Updates.setOnInsert(GridFsEntry::id.serialName, Id<GridFsEntry>()),
+                               Updates.setOnInsert(GridFsEntry::file.serialName, emptyFileId),
+                               Updates.setOnInsert(GridFsEntry::created.serialName, now),
+                               Updates.setOnInsert(GridFsEntry::updated.serialName, now),
+                       )
+               )
+       }
+       
+       private suspend fun getSuffix(fullPath: StoragePath, forDir: Boolean = false) = try {
+               val pathParts = fullPath.elements
+               
+               coroutineScope {
+                       val indices = (if (forDir) 0 else 1)..pathParts.lastIndex
+                       
+                       indices.map { index ->
+                               val path = StoragePath(pathParts.dropLast(index))
+                               launch {
+                                       if (testExact(path)) throw FileAlreadyExistsException(path.toString())
+                               }
+                       }
+               }
+               
+               null
+       } catch (ex: FileAlreadyExistsException) {
+               StoragePath(ex.file)
+       }
+       
+       private lateinit var emptyFileId: ObjectId
+       
+       private suspend fun getOrCreateEmptyFile(): ObjectId {
+               bucket
+                       .find(Filters.and(Filters.eq("length", 0), Filters.eq("filename", GRID_FS_KEEP)))
+                       .awaitFirstOrNull()
+                       ?.objectId
+                       ?.let { return it }
+               
+               val bytesPublisher = { ByteBuffer.allocate(0) }
+                       .asFlow()
+                       .asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO)
+               
+               return bucket.uploadFromPublisher(GRID_FS_KEEP, bytesPublisher).awaitFirst()
+       }
+       
+       override suspend fun prepare() {
+               table.unique(GridFsEntry::path)
+               emptyFileId = getOrCreateEmptyFile()
+       }
+       
+       override suspend fun getType(path: StoragePath): StoredFileType? {
+               return if (getExact(path) != null)
+                       StoredFileType.FILE
+               else if (countPrefix(path) > 0)
+                       StoredFileType.DIRECTORY
+               else null
+       }
+       
+       override suspend fun createDir(dir: StoragePath): Boolean {
+               if (dir.isRoot) return true
+               if (getSuffix(dir, forDir = true) != null) return false
+               
+               createPrefix(dir)
+               return true
+       }
+       
+       override suspend fun listDir(dir: StoragePath): Map<String, StoredFileType>? {
+               val prefixPath = toPrefixPath(dir)
+               val allEntries = getPrefix(dir).map {
+                       val subPath = it.path.removePrefix(prefixPath)
+                       if (subPath.contains('/'))
+                               subPath.substringBefore('/') to StoredFileType.DIRECTORY
+                       else
+                               subPath to StoredFileType.FILE
+               }.toList().toMap()
+               
+               if (allEntries.isEmpty())
+                       return null
+               
+               return allEntries.filterKeys { it != GRID_FS_KEEP }
+       }
+       
+       override suspend fun deleteDir(dir: StoragePath): Boolean {
+               if (dir.isRoot) return false
+               deletePrefix(dir)
+               return true
+       }
+       
+       override suspend fun statFile(path: StoragePath): StoredFileStats? {
+               if (path.isRoot) return null
+               val file = getExact(path) ?: return null
+               val gridFsFile = bucket.find(Filters.eq(MONGODB_ID_KEY, file.file)).awaitFirst()
+               return StoredFileStats(file.updated, gridFsFile.length)
+       }
+       
+       override suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean {
+               if (path.isRoot) return false
+               if (getSuffix(path) != null) return false
+               if (countPrefix(path) > 0) return false
+               
+               val bytesPublisher = flow {
+                       emit(ByteBuffer.wrap(content))
+               }.asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO)
+               
+               val newId = bucket.uploadFromPublisher(path.elements.last(), bytesPublisher).awaitFirst()
+               updateExact(path, newId)
+               return true
+       }
+       
+       override suspend fun readFile(path: StoragePath): ByteArray? {
+               if (path.isRoot) return null
+               val file = getExact(path) ?: return null
+               val gridFsId = file.file
+               
+               return ByteArrayOutputStream().also { content ->
+                       bucket.downloadToPublisher(gridFsId).asFlow().collect { buffer ->
+                               val array = buffer.slice().moveToByteArray()
+                               withContext(Dispatchers.IO) { content.write(array) }
+                       }
+               }.toByteArray()
+       }
+       
+       override suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean {
+               if (source.isRoot || target.isRoot) return false
+               if (getSuffix(target) != null) return false
+               val sourceFile = getExact(source) ?: return false
+               updateExact(target, sourceFile.file)
+               return true
+       }
+       
+       override suspend fun eraseFile(path: StoragePath): Boolean {
+               if (path.isRoot) return false
+               return deleteExact(path)
+       }
+       
+       override suspend fun performMaintenance() {
+               val allUsedIds = table.all().map { it.file }.toSet()
+               val unusedFiles = bucket.find(
+                       Filters.and(
+                               Filters.nin(MONGODB_ID_KEY, allUsedIds),
+                               Filters.ne("filename", GRID_FS_KEEP)
+                       )
+               ).asFlow().map { it.objectId }.toSet()
+               
+               coroutineScope {
+                       unusedFiles.map { unusedFile ->
+                               launch {
+                                       bucket.delete(unusedFile).awaitFirst()
+                               }
+                       }.joinAll()
+               }
+       }
+       
+       companion object {
+               private const val GRID_FS_KEEP = ".grid-fs-keep"
+       }
+}
index eaf60e023f62d30aaf977e28dee768c332be736b..21de217ee435af8f2057dfe6a195aab0f9b600cb 100644 (file)
@@ -10,7 +10,7 @@ import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.runBlocking
 import kotlin.system.exitProcess
 
-private fun printUsage(): Nothing {
+fun printUsage(): Nothing {
        println("Usage: <FROM> <TO>")
        println("Both arguments are of either following format:")
        println("    gridfs - use GridFS (database connection indicated by config.json)")
@@ -19,7 +19,7 @@ private fun printUsage(): Nothing {
        exitProcess(-1)
 }
 
-private fun String.parseStorage(): FileStorageConfig {
+fun String.parseStorage(): FileStorageConfig {
        val configuration = Configuration.Current
        
        return if (this == "config")
@@ -49,12 +49,12 @@ private suspend fun migrateDir(path: StoragePath, from: FileStorage, into: FileS
        val inDir = from.listDir(path) ?: return listOf("[Source Error] Directory at /$path does not exist")
        
        return coroutineScope {
-               inDir.map { entry ->
+               inDir.map { (name, type) ->
                        async {
-                               val entryPath = path / entry.name
-                               when (entry.type) {
-                                       StoredFileType.FILE -> migrateFile(entryPath, from, into)
-                                       StoredFileType.DIRECTORY -> migrateDir(entryPath, from, into)
+                               val subPath = path / name
+                               when (type) {
+                                       StoredFileType.FILE -> migrateFile(subPath, from, into)
+                                       StoredFileType.DIRECTORY -> migrateDir(subPath, from, into)
                                }
                        }
                }.awaitAll().flatten()
@@ -66,19 +66,23 @@ private suspend fun migrateRoot(from: FileStorage, into: FileStorage): List<Stri
                ?: return listOf("[Source Error] Root directory does not exist")
        
        return coroutineScope {
-               inRoot.map { entry ->
+               inRoot.map { (name, type) ->
                        async {
-                               val entryPath = StoragePath.Root / entry.name
-                               when (entry.type) {
-                                       StoredFileType.FILE -> migrateFile(entryPath, from, into)
-                                       StoredFileType.DIRECTORY -> migrateDir(entryPath, from, into)
+                               val subPath = StoragePath.Root / name
+                               when (type) {
+                                       StoredFileType.FILE -> migrateFile(subPath, from, into)
+                                       StoredFileType.DIRECTORY -> migrateDir(subPath, from, into)
                                }
                        }
                }.awaitAll().flatten()
        }
 }
 
-fun main(args: Array<String>) {
+fun interface FileStorageMigrator {
+       suspend fun migrateRoot(from: FileStorage, into: FileStorage): List<String>
+}
+
+fun doMigration(args: Array<String>, migrator: FileStorageMigrator) {
        if (args.size != 2) {
                println("Invalid number of arguments ${args.size}, expected 2")
                printUsage()
@@ -98,7 +102,7 @@ fun main(args: Array<String>) {
                val fromStorage = FileStorage(from)
                val intoStorage = FileStorage(into)
                
-               migrateRoot(fromStorage, intoStorage)
+               migrator.migrateRoot(fromStorage, intoStorage)
        }
        
        if (errors.isEmpty())
@@ -109,3 +113,7 @@ fun main(args: Array<String>) {
                        println(error)
        }
 }
+
+fun main(args: Array<String>) {
+       doMigration(args, ::migrateRoot)
+}
index 784b6bc6ba1d5e1f392f08273d2c1149d81e6d03..231c609cc9c4dcae2afffb18cae77fe34788180d 100644 (file)
@@ -2,35 +2,6 @@
 
 package info.mechyrdia.data
 
-import info.mechyrdia.Configuration
-import info.mechyrdia.FileStorageConfig
-import kotlinx.coroutines.runBlocking
-import kotlin.system.exitProcess
-
-private fun printUsage(): Nothing {
-       println("Usage: <FROM> <TO>")
-       println("Both arguments are of either following format:")
-       println("    gridfs - use GridFS (database connection indicated by config.json)")
-       println("    config - storage indicated in config file")
-       println("    file:<relative-path> - use flat-file storage")
-       exitProcess(-1)
-}
-
-private fun String.parseStorage(): FileStorageConfig {
-       val configuration = Configuration.Current
-       
-       return if (this == "config")
-               configuration.storage
-       else if (this == "gridfs")
-               FileStorageConfig.GridFs
-       else if (startsWith("file:"))
-               FileStorageConfig.Flat(removePrefix("file:"))
-       else {
-               println("Invalid format for argument value $this")
-               printUsage()
-       }
-}
-
 private suspend fun migrateFile(path: StoragePath, from: FileStorage, into: FileStorage): List<String> {
        println("[Message] Starting transfer of /$path")
        
@@ -48,11 +19,11 @@ private suspend fun migrateDir(path: StoragePath, from: FileStorage, into: FileS
        
        val inDir = from.listDir(path) ?: return listOf("[Source Error] Directory at /$path does not exist")
        
-       return inDir.flatMap { entry ->
-               val entryPath = path / entry.name
-               when (entry.type) {
-                       StoredFileType.FILE -> migrateFile(entryPath, from, into)
-                       StoredFileType.DIRECTORY -> migrateDir(entryPath, from, into)
+       return inDir.flatMap { (name, type) ->
+               val subPath = path / name
+               when (type) {
+                       StoredFileType.FILE -> migrateFile(subPath, from, into)
+                       StoredFileType.DIRECTORY -> migrateDir(subPath, from, into)
                }
        }
 }
@@ -61,43 +32,15 @@ private suspend fun migrateRoot(from: FileStorage, into: FileStorage): List<Stri
        val inRoot = from.listDir(StoragePath.Root)
                ?: return listOf("[Source Error] Root directory does not exist")
        
-       return inRoot.flatMap { entry ->
-               val entryPath = StoragePath.Root / entry.name
-               when (entry.type) {
-                       StoredFileType.FILE -> migrateFile(entryPath, from, into)
-                       StoredFileType.DIRECTORY -> migrateDir(entryPath, from, into)
+       return inRoot.flatMap { (name, type) ->
+               val subPath = StoragePath.Root / name
+               when (type) {
+                       StoredFileType.FILE -> migrateFile(subPath, from, into)
+                       StoredFileType.DIRECTORY -> migrateDir(subPath, from, into)
                }
        }
 }
 
 fun main(args: Array<String>) {
-       if (args.size != 2) {
-               println("Invalid number of arguments ${args.size}, expected 2")
-               printUsage()
-       }
-       
-       val (from, into) = args.map { it.parseStorage() }
-       if (from == into) {
-               println("Cannot migrate storage to itself")
-               printUsage()
-       }
-       
-       val errors = runBlocking {
-               System.setProperty("logback.statusListenerClass", "ch.qos.logback.core.status.NopStatusListener")
-               
-               ConnectionHolder.initialize(Configuration.Current.dbConn, Configuration.Current.dbName)
-               
-               val fromStorage = FileStorage(from)
-               val intoStorage = FileStorage(into)
-               
-               migrateRoot(fromStorage, intoStorage)
-       }
-       
-       if (errors.isEmpty())
-               println("Successful migration! No errors encountered!")
-       else {
-               println("Migration encountered ${errors.size} ${errors.size.pluralize("error")}")
-               for (error in errors)
-                       println(error)
-       }
+       doMigration(args, ::migrateRoot)
 }
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt
new file mode 100644 (file)
index 0000000..e76dfd1
--- /dev/null
@@ -0,0 +1,196 @@
+package info.mechyrdia.data
+
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.lore.*
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
+import io.ktor.server.application.*
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.html.*
+import java.time.Instant
+
+data class CommentRenderData(
+       val id: Id<Comment>,
+       
+       val submittedBy: NationData,
+       val submittedIn: List<String>,
+       val submittedAt: Instant,
+       
+       val submittedInTitle: String,
+       
+       val numEdits: Int,
+       val lastEdit: Instant?,
+       
+       val contentsRaw: String,
+       val contentsHtml: TagConsumer<*>.() -> Any?,
+       
+       val replyLinks: List<Id<Comment>>,
+) {
+       companion object {
+               suspend operator fun invoke(comments: List<Comment>, nations: MutableMap<Id<NationData>, NationData> = mutableMapOf()): List<CommentRenderData> {
+                       return coroutineScope {
+                               comments.map { comment ->
+                                       async {
+                                               val nationDataAsync = async { nations.getNation(comment.submittedBy) }
+                                               val pageTitleAsync = async { (StoragePath.articleDir / comment.submittedIn).toFriendlyPathTitle() }
+                                               val htmlResult = comment.contents.parseAs(ParserTree::toCommentHtml)
+                                               
+                                               CommentRenderData(
+                                                       id = comment.id,
+                                                       submittedBy = nationDataAsync.await(),
+                                                       submittedIn = comment.submittedIn.split('/'),
+                                                       submittedAt = comment.submittedAt,
+                                                       submittedInTitle = pageTitleAsync.await(),
+                                                       numEdits = comment.numEdits,
+                                                       lastEdit = comment.lastEdit,
+                                                       contentsRaw = comment.contents,
+                                                       contentsHtml = htmlResult,
+                                                       replyLinks = CommentReplyLink.getReplies(comment.id),
+                                               )
+                                       }
+                               }.awaitAll()
+                       }
+               }
+       }
+}
+
+context(ApplicationCall)
+fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData>?, viewingUserPage: Boolean = false) {
+       if (comment.submittedBy.isBanned && !viewingUserPage && loggedInAs != comment.submittedBy.id && loggedInAs != OwnerNationId)
+               return
+       
+       if (viewingUserPage)
+               p {
+                       style = "font-size:1.5em;margin-top:2.5em"
+                       +"On factbook "
+                       a(href = href(Root.LorePage(comment.submittedIn))) {
+                               +comment.submittedInTitle
+                       }
+               }
+       
+       div(classes = "comment-box") {
+               id = "comment-${comment.id}"
+               div(classes = "comment-author") {
+                       img(src = comment.submittedBy.flag, alt = "Flag of ${comment.submittedBy.name}", classes = "flag-icon")
+                       span(classes = "author-name") {
+                               +Entities.nbsp
+                               a(href = href(Root.User.ById(comment.submittedBy.id))) {
+                                       +comment.submittedBy.name
+                               }
+                       }
+                       span(classes = "posted-at") {
+                               dateTime(comment.submittedAt)
+                       }
+               }
+               
+               div(classes = "comment") {
+                       +comment.contentsHtml
+                       comment.lastEdit?.let { lastEdit ->
+                               p {
+                                       style = "font-size:0.8em"
+                                       +"Edited ${comment.numEdits} ${comment.numEdits.pluralize("time")}, last edited at "
+                                       dateTime(lastEdit)
+                               }
+                       }
+                       p {
+                               style = "font-size:0.8em"
+                               a(href = href(Root.Comments.ViewPage(comment.id))) {
+                                       +"Permalink"
+                               }
+                               +Entities.nbsp
+                               a(href = "#", classes = "copy-text") {
+                                       attributes["data-text"] = "https://mechyrdia.info${href(Root.Comments.ViewPage(comment.id))}"
+                                       +"(Copy)"
+                               }
+                               
+                               if (loggedInAs != null) {
+                                       +Entities.nbsp
+                                       +"\u2022"
+                                       +Entities.nbsp
+                                       a(href = "#", classes = "copy-text") {
+                                               attributes["data-text"] = "[reply]${comment.id}[/reply]"
+                                               +"Reply"
+                                       }
+                                       
+                                       +Entities.nbsp
+                                       +"\u2022"
+                                       +Entities.nbsp
+                                       a(href = "#", classes = "copy-text") {
+                                               attributes["data-text"] = "[quote]${comment.contentsRaw}[/quote][reply]${comment.id}[/reply]"
+                                               +"Quote Reply"
+                                       }
+                               }
+                               
+                               if (loggedInAs == comment.submittedBy.id) {
+                                       +Entities.nbsp
+                                       +"\u2022"
+                                       +Entities.nbsp
+                                       a(href = "#", classes = "comment-edit-link") {
+                                               attributes["data-edit-id"] = "comment-edit-box-${comment.id}"
+                                               +"Edit"
+                                       }
+                               }
+                               
+                               if (loggedInAs == comment.submittedBy.id || loggedInAs == OwnerNationId) {
+                                       +Entities.nbsp
+                                       +"\u2022"
+                                       +Entities.nbsp
+                                       a(href = href(Root.Comments.DeleteConfirmPage(comment.id)), classes = "comment-delete-link") {
+                                               +"Delete"
+                                       }
+                               }
+                       }
+                       if (comment.replyLinks.isNotEmpty())
+                               p {
+                                       style = "font-size:0.8em"
+                                       +"Replies:"
+                                       for (reply in comment.replyLinks) {
+                                               +" "
+                                               a(href = href(Root.Comments.ViewPage(reply))) {
+                                                       +">>$reply"
+                                               }
+                                       }
+                               }
+               }
+       }
+       
+       if (loggedInAs == comment.submittedBy.id) {
+               val formPath = href(Root.Comments.EditPost(comment.id))
+               form(action = formPath, method = FormMethod.post, classes = "comment-input comment-edit-box") {
+                       id = "comment-edit-box-${comment.id}"
+                       div(classes = "comment-preview")
+                       textArea(classes = "comment-markup") {
+                               name = "comment"
+                               +comment.contentsRaw
+                       }
+                       installCsrfToken()
+                       submitInput { value = "Edit Comment" }
+                       button(classes = "comment-cancel-edit evil") {
+                               +"Cancel Editing"
+                       }
+               }
+       }
+}
+
+context(ApplicationCall)
+fun FlowContent.commentInput(pagePathParts: List<String>, commentingAs: NationData?) {
+       if (commentingAs == null) {
+               p {
+                       a(href = href(Root.Auth.LoginPage())) { +"Log in" }
+                       +" to comment"
+               }
+               return
+       }
+       
+       form(action = href(Root.Comments.NewPost(path = pagePathParts)), method = FormMethod.post, classes = "comment-input") {
+               div(classes = "comment-preview")
+               textArea(classes = "comment-markup") {
+                       name = "comment"
+               }
+               installCsrfToken()
+               submitInput { value = "Submit Comment" }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt
new file mode 100644 (file)
index 0000000..e19bb69
--- /dev/null
@@ -0,0 +1,513 @@
+package info.mechyrdia.data
+
+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
+import io.ktor.server.application.*
+import kotlinx.coroutines.flow.filterNot
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.html.*
+import java.time.Instant
+
+suspend fun ApplicationCall.recentCommentsPage(limit: Int?): HTML.() -> Unit {
+       limit ?: redirectHref(Root.Comments.RecentPage(10))
+       
+       val currNation = currentNation()
+       
+       val validLimits = listOf(10, 20, 50, 80, 100)
+       
+       if (limit !in validLimits)
+               redirectHref(Root.Comments.RecentPage(limit = 10))
+       
+       val comments = CommentRenderData(
+               Comment.Table
+                       .sorted(Sorts.descending(Comment::submittedAt.serialName))
+                       .filterNot { comment ->
+                               comment.submittedBy != currNation?.id && NationData.get(comment.submittedBy).isBanned
+                       }
+                       .take(limit)
+                       .toList(),
+               nationCache
+       )
+       
+       return page("Recent Comments", standardNavBar()) {
+               section {
+                       h1 { +"Recent Comments" }
+                       
+                       p {
+                               +"Number of comments to view: "
+                               for ((i, validLimit) in validLimits.withIndex()) {
+                                       if (i != 0)
+                                               +Entities.nbsp
+                                       
+                                       if (limit == validLimit)
+                                               strong {
+                                                       +"$validLimit"
+                                               }
+                                       else
+                                               a(href = href(Root.Comments.RecentPage(limit = validLimit))) {
+                                                       +"$validLimit"
+                                               }
+                               }
+                       }
+                       
+                       for (comment in comments)
+                               commentBox(comment, currNation?.id, viewingUserPage = true)
+               }
+       }
+}
+
+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"))))
+       
+       if (contents.isBlank())
+               redirectHref(Root.LorePage(pagePathParts, root = Root(error = "Comments may not be blank")))
+       
+       val now = Instant.now()
+       val comment = Comment(
+               id = Id(),
+               submittedBy = loggedInAs.id,
+               submittedIn = pagePathParts.joinToString("/"),
+               submittedAt = now,
+               
+               numEdits = 0,
+               lastEdit = null,
+               
+               contents = contents
+       )
+       
+       Comment.Table.put(comment)
+       CommentReplyLink.updateComment(comment.id, getReplies(contents), now)
+       
+       redirectHref(Root.LorePage(pagePathParts), hash = "comment-${comment.id}")
+}
+
+suspend fun ApplicationCall.viewCommentRoute(commentId: Id<Comment>): Nothing {
+       val comment = Comment.Table.get(commentId)!!
+       
+       val currentNation = currentNation()
+       val submitter = nationCache.getNation(comment.submittedBy)
+       
+       if (submitter.isBanned && currentNation?.id != comment.submittedBy && currentNation?.id != OwnerNationId)
+               throw NoSuchElementException("Shadowbanned comment")
+       
+       val pagePathParts = comment.submittedIn.split('/')
+       val errorMessage = attributes.getOrNull(ErrorMessageAttributeKey)
+       redirectHref(Root.LorePage(pagePathParts, root = Root(errorMessage)), 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"))))
+       
+       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"))))
+       
+       // Check for null edits, i.e. edits that don't change anything
+       if (newContents == oldComment.contents)
+               redirectHref(Root.Comments.ViewPage(oldComment.id))
+       
+       val now = Instant.now()
+       val newComment = oldComment.copy(
+               numEdits = oldComment.numEdits + 1,
+               lastEdit = now,
+               contents = newContents
+       )
+       
+       Comment.Table.put(newComment)
+       CommentReplyLink.updateComment(commentId, getReplies(newContents), now)
+       
+       redirectHref(Root.Comments.ViewPage(oldComment.id))
+}
+
+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 comment = Comment.Table.get(commentId)!!
+       
+       if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
+               throw ForbiddenException("Illegal attempt by ${currNation.id} to delete comment by ${comment.submittedBy}")
+       
+       return currNation to comment
+}
+
+suspend fun ApplicationCall.deleteCommentPage(commentId: Id<Comment>): HTML.() -> Unit {
+       val (currNation, comment) = getCommentForDeletion(commentId)
+       
+       val commentDisplay = CommentRenderData(listOf(comment), nationCache).single()
+       
+       return page("Confirm Deletion of Comment", standardNavBar()) {
+               section {
+                       p {
+                               +"Are you sure you want to delete this comment? "
+                               strong { +"It will be gone forever!" }
+                       }
+                       
+                       commentBox(commentDisplay, currNation.id)
+                       
+                       form(method = FormMethod.get, action = href(Root.Comments.ViewPage(comment.id))) {
+                               submitInput { value = "No, take me back" }
+                       }
+                       form(method = FormMethod.post, action = href(Root.Comments.DeleteConfirmPost(comment.id))) {
+                               installCsrfToken()
+                               submitInput(classes = "evil") { value = "Yes, delete it" }
+                       }
+               }
+       }
+}
+
+suspend fun ApplicationCall.deleteCommentRoute(commentId: Id<Comment>): Nothing {
+       val (_, comment) = getCommentForDeletion(commentId)
+       
+       Comment.Table.del(comment.id)
+       CommentReplyLink.deleteComment(comment.id)
+       
+       val pagePathParts = comment.submittedIn.split('/')
+       redirectHref(Root.LorePage(pagePathParts), hash = "comments")
+}
+
+suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commenting Help", standardNavBar()) {
+       section {
+               h1 { +"Commenting Help" }
+               p { +"Comments on this repository support a subset of the BBCode used in factbook markup." }
+               p { +"The following tags are supported:" }
+               table {
+                       thead {
+                               tr {
+                                       th { +"Tag" }
+                                       th { +"Purpose" }
+                               }
+                       }
+                       tbody {
+                               tr {
+                                       td { +"[b]Text goes here[/b]" }
+                                       td {
+                                               span {
+                                                       style = "font-weight:bold"
+                                                       +"Emboldens"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[i]Text goes here[/i]" }
+                                       td {
+                                               span {
+                                                       style = "font-style:italic"
+                                                       +"Italicizes"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[u]Text goes here[/u]" }
+                                       td {
+                                               span {
+                                                       style = "text-decoration: underline"
+                                                       +"Underlines"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[s]Text goes here[/s]" }
+                                       td {
+                                               span {
+                                                       style = "text-decoration: line-through"
+                                                       +"Strikes out"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[sup]Text goes here[/sup]" }
+                                       td {
+                                               sup {
+                                                       +"Superscripts"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[sub]Text goes here[/sub]" }
+                                       td {
+                                               sub {
+                                                       +"Subscripts"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[color=#CC8844]Text goes here[/sub]" }
+                                       td {
+                                               span {
+                                                       style = "color:#CC8844"
+                                                       +"Colors"
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[ipa]Text goes here[/ipa]" }
+                                       td {
+                                               span {
+                                                       style = "font-family:DejaVu Sans"
+                                                       +"Applies IPA font to "
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[code]Text goes here[/code]" }
+                                       td {
+                                               span {
+                                                       style = "font-family:JetBrains Mono"
+                                                       +"Applies code font to "
+                                               }
+                                               +" text"
+                                       }
+                               }
+                               tr {
+                                       td { +"[align=(left, center, right, or justify)]Text goes here[/align]" }
+                                       td {
+                                               +"Aligns text on the left, center, right, or justified"
+                                       }
+                               }
+                               tr {
+                                       td { +"e.g. [align=center]Text goes here[/align]" }
+                                       td {
+                                               div {
+                                                       style = "text-align: center"
+                                                       +"Center-aligns text"
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[aside=(left or right)]Text goes here[/aside]" }
+                                       td {
+                                               +"Creates a floating block to the side, on either the left or the right"
+                                       }
+                               }
+                               tr {
+                                       td { +"[ul][li]List items go here[/li]... [/ul]" }
+                                       td {
+                                               +"Creates a bullet list, e.g."
+                                               ul {
+                                                       li { +"Item" }
+                                                       li { +"The cooler item" }
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[ol][li]List items go here[/li]... [/ol]" }
+                                       td {
+                                               +"Creates a numbered list, e.g."
+                                               ol {
+                                                       li { +"First item" }
+                                                       li { +"Second item" }
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[table](table rows go here...)[/table]" }
+                                       td {
+                                               +"The root element of a table"
+                                       }
+                               }
+                               tr {
+                                       td { +"[tr](table cells go here...)[/tr]" }
+                                       td {
+                                               +"A row of a table"
+                                       }
+                               }
+                               tr {
+                                       td { +"[th]Text goes here[/th]" }
+                                       td {
+                                               +"A heading cell of a table"
+                                       }
+                               }
+                               tr {
+                                       td { +"[td]Text goes here[/td]" }
+                                       td {
+                                               +"A data cell of a table"
+                                       }
+                               }
+                       }
+               }
+               val tableDemoMarkup =
+                       """
+                               |[table]
+                               |[tr]
+                               |[th=2x2][i]ab[/i][sup]-1[/sup] mod 10[/th]
+                               |[th=10][i]a[/i][/th]
+                               |[/tr]
+                               |[tr]
+                               |[th]0[/th]
+                               |[th]1[/th]
+                               |[th]2[/th]
+                               |[th]3[/th]
+                               |[th]4[/th]
+                               |[th]5[/th]
+                               |[th]6[/th]
+                               |[th]7[/th]
+                               |[th]8[/th]
+                               |[th]9[/th]
+                               |[/tr]
+                               |[tr]
+                               |[th=x4][i]b[/i][/th]
+                               |[th]1[/th]
+                               |[td]0[/td]
+                               |[td]1[/td]
+                               |[td]2[/td]
+                               |[td]3[/td]
+                               |[td]4[/td]
+                               |[td]5[/td]
+                               |[td]6[/td]
+                               |[td]7[/td]
+                               |[td]8[/td]
+                               |[td]9[/td]
+                               |[/tr]
+                               |[tr]
+                               |[th]3[/th]
+                               |[td]0[/td]
+                               |[td]7[/td]
+                               |[td]4[/td]
+                               |[td]1[/td]
+                               |[td]8[/td]
+                               |[td]5[/td]
+                               |[td]2[/td]
+                               |[td]9[/td]
+                               |[td]6[/td]
+                               |[td]3[/td]
+                               |[/tr]
+                               |[tr]
+                               |[th]7[/th]
+                               |[td]0[/td]
+                               |[td]3[/td]
+                               |[td]6[/td]
+                               |[td]9[/td]
+                               |[td]2[/td]
+                               |[td]5[/td]
+                               |[td]8[/td]
+                               |[td]1[/td]
+                               |[td]4[/td]
+                               |[td]7[/td]
+                               |[/tr]
+                               |[tr]
+                               |[th]9[/th]
+                               |[td]0[/td]
+                               |[td]9[/td]
+                               |[td]8[/td]
+                               |[td]7[/td]
+                               |[td]6[/td]
+                               |[td]5[/td]
+                               |[td]4[/td]
+                               |[td]3[/td]
+                               |[td]2[/td]
+                               |[td]1[/td]
+                               |[/tr]
+                               |[/table]
+                       """.trimMargin()
+               val tableDemoHtml = tableDemoMarkup.parseAs(ParserTree::toCommentHtml)
+               p {
+                       +"Table cells in this custom BBCode markup also support row-spans and column-spans, even at the same time:"
+               }
+               pre { +tableDemoMarkup }
+               +tableDemoHtml
+               p {
+                       +"The format goes as [td=(width)x(height)] or [th=(width)x(height)]. If one parameter is omitted (assumed to be 1), then the format can be [td=(width)] or [td=x(height)]"
+               }
+               table {
+                       thead {
+                               tr {
+                                       th { +"Tag" }
+                                       th { +"Purpose" }
+                               }
+                       }
+                       tbody {
+                               tr {
+                                       td { +"[url=https://google.com/]Text goes here[/url]" }
+                                       td {
+                                               +"Creates an "
+                                               a(href = "https://google.com/") {
+                                                       rel = "nofollow"
+                                                       +"HTML link"
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[imgbb=256x256]Lns12z1/robert-sparr.png[/imgbb]" }
+                                       td {
+                                               p {
+                                                       +"Creates an embedded image:"
+                                                       br
+                                                       img(src = "https://i.ibb.co/Lns12z1/robert-sparr.png") {
+                                                               style = getImageSizeStyleValue(256, 256)
+                                                       }
+                                                       br
+                                                       +"The tag param controls the width and height, much like a table cell. The size unit is viewport-responsive and has no correlation with pixels."
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[reply](comment id)[/reply]" }
+                                       td { +"Creates a reply link to a comment" }
+                               }
+                               tr {
+                                       td { +"[quote]Quoted text[/quote]" }
+                                       td {
+                                               +"Creates a "
+                                               blockQuote {
+                                                       +"block-level quote"
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[lang=tylan]Rheagda Tulasra[/lang]" }
+                                       td {
+                                               +"Writes text in the Tylan alphabet: "
+                                               span(classes = "lang-tylan") {
+                                                       +TylanAlphabetFont.tylanToFontAlphabet("rheagda tulasra")
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[lang=thedish]Theodisc Rasda[/lang]" }
+                                       td {
+                                               +"Writes text in the Thedish alphabet: "
+                                               span(classes = "lang-thedish") {
+                                                       +"Theodisc Rasda"
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[lang=pokhval]Pokhvalsko Jaargo[/lang]" }
+                                       td {
+                                               +"Writes text in the Pokhwalish alphabet: "
+                                               span(classes = "lang-pokhwal") {
+                                                       +PokhwalishAlphabetFont.pokhwalToFontAlphabet("pokhvalsqo jaargo")
+                                               }
+                                       }
+                               }
+                               tr {
+                                       td { +"[lang=kishari]KyÅŸary lanur[/lang]" }
+                                       td {
+                                               +"Writes text in the Kishari alphabet: "
+                                               span(classes = "lang-kishari") {
+                                                       +"kyÅŸary lanur"
+                                               }
+                                       }
+                               }
+                       }
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsFiles.kt
new file mode 100644 (file)
index 0000000..3384d4c
--- /dev/null
@@ -0,0 +1,409 @@
+package info.mechyrdia.data
+
+import info.mechyrdia.auth.PageDoNotCacheAttributeKey
+import info.mechyrdia.lore.adminPage
+import info.mechyrdia.lore.dateTime
+import info.mechyrdia.lore.redirectHref
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
+import io.ktor.http.*
+import io.ktor.http.content.*
+import io.ktor.server.application.*
+import io.ktor.server.html.*
+import io.ktor.server.plugins.*
+import io.ktor.server.response.*
+import kotlinx.coroutines.*
+import kotlinx.html.*
+
+private fun Map<String, StoredFileType>.sortedAsFiles() = toList()
+       .sortedBy { (name, _) -> name }
+       .sortedBy { (_, type) -> type }
+
+private sealed class TreeNode {
+       data class FileNode(val stats: StoredFileStats) : TreeNode()
+       data class DirNode(val children: Map<String, TreeNode>) : TreeNode()
+}
+
+private val TreeNode.sortIndex: Int
+       get() = when (this) {
+               is TreeNode.FileNode -> 1
+               is TreeNode.DirNode -> 0
+       }
+
+private fun Map<String, TreeNode>.sortedAsFiles() = toList()
+       .sortedBy { (name, _) -> name }
+       .sortedBy { (_, node) -> node.sortIndex }
+
+private suspend fun fileTree(path: StoragePath): TreeNode? {
+       return FileStorage.instance.statFile(path)?.let {
+               TreeNode.FileNode(it)
+       } ?: coroutineScope {
+               FileStorage.instance.listDir(path)?.map { (name, _) ->
+                       async {
+                               fileTree(path / name)?.let { name to it }
+                       }
+               }?.awaitAll()
+                       ?.filterNotNull()
+                       ?.toMap()
+                       ?.let { TreeNode.DirNode(it) }
+       }
+}
+
+context(ApplicationCall)
+private fun UL.render(path: StoragePath, childNodes: Map<String, TreeNode>) {
+       val sortedChildren = childNodes.sortedAsFiles()
+       
+       for ((name, child) in sortedChildren)
+               render(path / name, child)
+       
+       li {
+               style = "list-style:none"
+               
+               p {
+                       form(action = href(Root.Admin.Vfs.Upload(path.elements)), method = FormMethod.post, encType = FormEncType.multipartFormData) {
+                               installCsrfToken()
+                               label {
+                                       fileInput(name = "uploaded")
+                                       +"Upload File"
+                               }
+                               submitInput()
+                       }
+               }
+               
+               p {
+                       form(action = href(Root.Admin.Vfs.MkDir(path.elements)), method = FormMethod.post) {
+                               installCsrfToken()
+                               textInput {
+                                       placeholder = "new-dir"
+                               }
+                               +Entities.nbsp
+                               submitInput {
+                                       value = "Make Directory"
+                               }
+                       }
+               }
+               
+               if (!path.isRoot)
+                       p {
+                               form(action = href(Root.Admin.Vfs.RmDirConfirmPage(path.elements)), method = FormMethod.get) {
+                                       submitInput(classes = "evil") {
+                                               value = "Delete (Recursive)"
+                                       }
+                               }
+                       }
+       }
+}
+
+context(ApplicationCall)
+private fun UL.render(path: StoragePath, node: TreeNode) {
+       when (node) {
+               is TreeNode.FileNode -> li {
+                       a(href = href(Root.Admin.Vfs.View(path.elements))) {
+                               +path.name
+                       }
+               }
+               
+               is TreeNode.DirNode -> li {
+                       a(href = href(Root.Admin.Vfs.View(path.elements))) {
+                               +path.name
+                       }
+                       ul {
+                               render(path, node.children)
+                       }
+               }
+       }
+}
+
+suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit {
+       val tree = fileTree(path)!!
+       
+       return adminPage("VFS - /$path") {
+               main {
+                       h1 { +"/$path" }
+                       
+                       when (tree) {
+                               is TreeNode.FileNode -> table {
+                                       tr {
+                                               th {
+                                                       colSpan = "2"
+                                                       +"/$path"
+                                               }
+                                       }
+                                       tr {
+                                               td {
+                                                       colSpan = "2"
+                                                       iframe {
+                                                               src = href(Root.Admin.Vfs.Inline(path.elements))
+                                                       }
+                                               }
+                                       }
+                                       tr {
+                                               th { +"Last updated" }
+                                               td { dateTime(tree.stats.updated) }
+                                       }
+                                       tr {
+                                               th { +"Size (bytes)" }
+                                               td { +"${tree.stats.size}" }
+                                       }
+                                       tr {
+                                               th { +"Actions" }
+                                               td {
+                                                       ul {
+                                                               li {
+                                                                       a(classes = "button", href = href(Root.Admin.Vfs.Download(path.elements))) {
+                                                                               +"Download"
+                                                                       }
+                                                               }
+                                                               li {
+                                                                       form(action = href(Root.Admin.Vfs.Overwrite(path.elements)), method = FormMethod.post, encType = FormEncType.multipartFormData) {
+                                                                               installCsrfToken()
+                                                                               label {
+                                                                                       fileInput(name = "uploaded")
+                                                                                       +"Upload New Version"
+                                                                               }
+                                                                               submitInput()
+                                                                       }
+                                                               }
+                                                               li {
+                                                                       a(classes = "button", href = href(Root.Admin.Vfs.CopyPage(path.elements))) {
+                                                                               +"Make Copy"
+                                                                       }
+                                                               }
+                                                               li {
+                                                                       a(classes = "button evil", href = href(Root.Admin.Vfs.DeleteConfirmPage(path.elements))) {
+                                                                               +"Delete"
+                                                                       }
+                                                               }
+                                                       }
+                                               }
+                                       }
+                                       tr {
+                                               th { +"Navigate" }
+                                               td {
+                                                       ul {
+                                                               path.elements.indices.forEach { index ->
+                                                                       val parent = path.elements.take(index)
+                                                                       li {
+                                                                               a(href = href(Root.Admin.Vfs.View(parent))) {
+                                                                                       +"/${StoragePath(parent)}"
+                                                                               }
+                                                                       }
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               }
+                               
+                               is TreeNode.DirNode -> ul {
+                                       if (!path.isRoot)
+                                               li {
+                                                       a(href = href(Root.Admin.Vfs.View(path.elements.dropLast(1)))) {
+                                                               +".."
+                                                       }
+                                               }
+                                       
+                                       render(path, tree.children)
+                               }
+                       }
+               }
+       }
+}
+
+private val textExtensions = listOf(
+       "",
+       "groovy",
+       "html",
+       "map",
+       "mtl",
+       "obj",
+       "old",
+       "tpl",
+       "wip",
+)
+
+suspend fun ApplicationCall.adminPreviewFile(path: StoragePath) {
+       attributes.put(PageDoNotCacheAttributeKey, true)
+       
+       val extension = path.elements.last().substringAfterLast('.', "")
+       val type = if (extension in textExtensions) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension)
+       val result = FileStorage.instance.readFile(path) ?: return respond(HttpStatusCode.NotFound)
+       respondBytes(result, type)
+}
+
+private suspend fun fileTreeForCopy(path: StoragePath): TreeNode.DirNode? {
+       return coroutineScope {
+               FileStorage.instance.listDir(path)?.map { (name, _) ->
+                       async {
+                               fileTreeForCopy(path / name)?.let { name to it }
+                       }
+               }?.awaitAll()
+                       ?.filterNotNull()
+                       ?.toMap()
+                       ?.let { TreeNode.DirNode(it) }
+       }
+}
+
+context(ApplicationCall)
+private fun UL.renderForCopy(fromPath: StoragePath, intoPath: StoragePath, node: TreeNode.DirNode) {
+       li {
+               form(method = FormMethod.post, action = href(Root.Admin.Vfs.CopyPost(intoPath.elements))) {
+                       installCsrfToken()
+                       hiddenInput(name = "from") { value = fromPath.toString() }
+                       submitInput { value = "Copy Into /$intoPath" }
+               }
+               ul {
+                       for ((childName, childNode) in node.children)
+                               if (childNode is TreeNode.DirNode)
+                                       renderForCopy(fromPath, intoPath / childName, childNode)
+               }
+       }
+}
+
+suspend fun ApplicationCall.adminShowCopyFile(from: StoragePath): HTML.() -> Unit {
+       if (FileStorage.instance.statFile(from) == null)
+               throw NoSuchElementException("File does not exist")
+       
+       val tree = fileTreeForCopy(StoragePath.Root)!!
+       
+       return adminPage("Copy File /$from") {
+               main {
+                       h1 { +"Choose Destination for /$from" }
+                       ul {
+                               li {
+                                       form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(from.elements))) {
+                                               submitInput { value = "Cancel Copy" }
+                                       }
+                               }
+                               renderForCopy(from, StoragePath.Root, tree)
+                       }
+               }
+       }
+}
+
+suspend fun ApplicationCall.adminDoCopyFile(from: StoragePath, into: StoragePath) {
+       val name = from.elements.last()
+       val dest = into / name
+       
+       if (FileStorage.instance.copyFile(from, dest))
+               redirectHref(Root.Admin.Vfs.View(dest.elements))
+       else
+               respond(HttpStatusCode.Conflict)
+}
+
+suspend fun ApplicationCall.adminUploadFile(path: StoragePath, part: PartData.FileItem) {
+       val name = part.originalFileName ?: throw MissingRequestParameterException("originalFileName")
+       val filePath = path / name
+       
+       val content = withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }
+       if (FileStorage.instance.writeFile(filePath, content))
+               redirectHref(Root.Admin.Vfs.View(filePath.elements))
+       else
+               respond(HttpStatusCode.Conflict)
+}
+
+suspend fun ApplicationCall.adminOverwriteFile(path: StoragePath, part: PartData.FileItem) {
+       if (FileStorage.instance.writeFile(path, withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }))
+               redirectHref(Root.Admin.Vfs.View(path.elements))
+       else
+               respond(HttpStatusCode.Conflict)
+}
+
+suspend fun ApplicationCall.adminConfirmDeleteFile(path: StoragePath) {
+       val stats = FileStorage.instance.statFile(path)
+       if (stats == null)
+               respond(HttpStatusCode.Conflict)
+       else
+               respondHtml(block = adminPage("Confirm Deletion of /$path") {
+                       main {
+                               p {
+                                       +"Are you sure you want to delete the file at /$path? "
+                                       strong { +"It will be gone forever!" }
+                               }
+                               table {
+                                       tr {
+                                               th { +"Last Updated" }
+                                               td { dateTime(stats.updated) }
+                                       }
+                                       tr {
+                                               th { +"Size (bytes)" }
+                                               td { +"${stats.size}" }
+                                       }
+                               }
+                               
+                               br
+                               
+                               div {
+                                       style = "text-align:center"
+                                       form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(path.elements))) {
+                                               submitInput { value = "No, take me back" }
+                                       }
+                                       +Entities.nbsp
+                                       form(method = FormMethod.post, action = href(Root.Admin.Vfs.DeleteConfirmPost(path.elements))) {
+                                               installCsrfToken()
+                                               submitInput(classes = "evil") { value = "Yes, delete it" }
+                                       }
+                               }
+                       }
+               })
+}
+
+suspend fun ApplicationCall.adminDeleteFile(path: StoragePath) {
+       if (FileStorage.instance.eraseFile(path))
+               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)))
+       else
+               respond(HttpStatusCode.Conflict)
+}
+
+suspend fun ApplicationCall.adminMakeDirectory(path: StoragePath, name: String) {
+       val dirPath = path / name
+       
+       if (FileStorage.instance.createDir(dirPath))
+               redirectHref(Root.Admin.Vfs.View(dirPath.elements))
+       else
+               respond(HttpStatusCode.Conflict)
+}
+
+suspend fun ApplicationCall.adminConfirmRemoveDirectory(path: StoragePath) {
+       val entries = FileStorage.instance.listDir(path)?.sortedAsFiles()
+       if (entries == null)
+               respond(HttpStatusCode.Conflict)
+       else
+               respondHtml(block = adminPage("Confirm Deletion of /$path") {
+                       main {
+                               p {
+                                       +"Are you sure you want to delete the directory at /$path? "
+                                       strong { +"It, and all of its contents, will be gone forever!" }
+                               }
+                               ul {
+                                       for ((name, type) in entries)
+                                               li {
+                                                       +name
+                                                       if (type == StoredFileType.DIRECTORY)
+                                                               +"/"
+                                               }
+                               }
+                               
+                               br
+                               
+                               div {
+                                       style = "text-align:center"
+                                       form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(path.elements))) {
+                                               submitInput { value = "No, take me back" }
+                                       }
+                                       +Entities.nbsp
+                                       form(method = FormMethod.post, action = href(Root.Admin.Vfs.RmDirConfirmPost(path.elements))) {
+                                               installCsrfToken()
+                                               submitInput(classes = "evil") { value = "Yes, delete it" }
+                                       }
+                               }
+                       }
+               })
+}
+
+suspend fun ApplicationCall.adminRemoveDirectory(path: StoragePath) {
+       if (FileStorage.instance.deleteDir(path))
+               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)))
+       else
+               respond(HttpStatusCode.Conflict)
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewsUser.kt
new file mode 100644 (file)
index 0000000..4b72023
--- /dev/null
@@ -0,0 +1,77 @@
+package info.mechyrdia.data
+
+import com.mongodb.client.model.Updates
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.auth.UserSession
+import info.mechyrdia.lore.NationProfileSidebar
+import info.mechyrdia.lore.page
+import info.mechyrdia.lore.redirectHref
+import info.mechyrdia.lore.standardNavBar
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import info.mechyrdia.route.installCsrfToken
+import io.ktor.server.application.*
+import io.ktor.server.sessions.*
+import kotlinx.coroutines.flow.toList
+import kotlinx.html.*
+
+fun ApplicationCall.currentUserPage(): Nothing {
+       val currNationId = sessions.get<UserSession>()?.nationId
+       if (currNationId == null)
+               redirectHref(Root.Auth.LoginPage())
+       else
+               redirectHref(Root.User.ById(currNationId))
+}
+
+suspend fun ApplicationCall.userPage(userId: Id<NationData>): HTML.() -> Unit {
+       val currNation = currentNation()
+       val viewingNation = nationCache.getNation(userId)
+       
+       val comments = CommentRenderData(
+               Comment.getCommentsBy(viewingNation.id).toList(),
+               nationCache
+       )
+       
+       return page(viewingNation.name, standardNavBar(), NationProfileSidebar(viewingNation)) {
+               section {
+                       a { id = "page-top" }
+                       h1 { +viewingNation.name }
+                       if (currNation?.id == OwnerNationId) {
+                               if (viewingNation.isBanned) {
+                                       p { +"This user is banned" }
+                                       val unbanLink = href(Root.Admin.Unban(viewingNation.id))
+                                       a(href = unbanLink) {
+                                               installCsrfToken(unbanLink)
+                                               +"Unban"
+                                       }
+                               } else {
+                                       val banLink = href(Root.Admin.Ban(viewingNation.id))
+                                       a(href = banLink) {
+                                               installCsrfToken(banLink)
+                                               +"Ban"
+                                       }
+                               }
+                       }
+                       for (comment in comments)
+                               commentBox(comment, currNation?.id, viewingUserPage = true)
+               }
+       }
+}
+
+suspend fun ApplicationCall.adminBanUserRoute(userId: Id<NationData>): Nothing {
+       val bannedNation = nationCache.getNation(userId)
+       
+       if (!bannedNation.isBanned)
+               NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, true))
+       
+       redirectHref(Root.User.ById(userId))
+}
+
+suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id<NationData>): Nothing {
+       val bannedNation = nationCache.getNation(userId)
+       
+       if (bannedNation.isBanned)
+               NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, false))
+       
+       redirectHref(Root.User.ById(userId))
+}
index c82a623d5a22dccac5df02d5e27b8dc8c1f766ae..02a9671934cea29a7c3df4d3ac7b4dc7c2576736 100644 (file)
@@ -28,7 +28,9 @@ import org.bson.codecs.kotlinx.KotlinSerializerCodecProvider
 import org.bson.conversions.Bson
 import java.security.SecureRandom
 import kotlin.reflect.KClass
+import kotlin.reflect.KProperty
 import kotlin.reflect.KProperty1
+import kotlin.reflect.full.findAnnotations
 import com.mongodb.reactivestreams.client.MongoDatabase as JMongoDatabase
 
 @Serializable(IdSerializer::class)
@@ -198,6 +200,23 @@ class DocumentTable<T : DataDocument<T>>(private val kClass: KClass<T>) {
 
 suspend inline fun <T : DataDocument<T>, reified R : Any> DocumentTable<T>.aggregate(pipeline: List<Bson>) = aggregate(pipeline, R::class)
 
+suspend inline fun <T : DataDocument<T>> DocumentTable<T>.getOrPut(id: Id<T>, defaultValue: () -> T): T {
+       val value = get(id)
+       return if (value == null) {
+               val answer = defaultValue()
+               if (answer.id != id) {
+                       throw IllegalArgumentException("Default value $answer has different Id than provided: $id")
+               }
+               put(answer)
+               answer
+       } else {
+               value
+       }
+}
+
+val <T> KProperty<T>.serialName: String
+       get() = findAnnotations(SerialName::class).singleOrNull()?.value ?: name
+
 inline fun <reified T : DataDocument<T>> DocumentTable() = DocumentTable(T::class)
 
 interface TableHolder<T : DataDocument<T>> {
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt b/src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt
deleted file mode 100644 (file)
index d7941e4..0000000
+++ /dev/null
@@ -1,456 +0,0 @@
-package info.mechyrdia.data
-
-import com.mongodb.client.model.Filters
-import com.mongodb.client.model.Updates
-import com.mongodb.reactivestreams.client.gridfs.GridFSBucket
-import info.mechyrdia.Configuration
-import info.mechyrdia.FileStorageConfig
-import info.mechyrdia.lore.StoragePathAttributeKey
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.response.*
-import io.ktor.util.*
-import io.ktor.util.cio.*
-import io.ktor.utils.io.*
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.reactive.asFlow
-import kotlinx.coroutines.reactive.asPublisher
-import kotlinx.coroutines.reactive.awaitFirst
-import kotlinx.coroutines.reactive.awaitFirstOrNull
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import org.bson.types.ObjectId
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.nio.ByteBuffer
-import java.nio.file.FileAlreadyExistsException
-import java.time.Instant
-import kotlin.String
-import kotlin.time.Duration.Companion.hours
-
-suspend fun ApplicationCall.respondStoredFile(fileStorage: FileStorage, path: StoragePath) {
-       val content = fileStorage.readFile(path) ?: return respond(HttpStatusCode.NotFound)
-       val extension = path.elements.last().substringAfter('.', "")
-       val type = if (extension.isEmpty()) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension)
-       
-       attributes.put(StoragePathAttributeKey, path)
-       respondBytes(content, type)
-}
-
-suspend fun ApplicationCall.respondStoredFile(path: StoragePath) {
-       return respondStoredFile(FileStorage.instance, path)
-}
-
-@JvmInline
-value class StoragePath(val elements: List<String>) {
-       init {
-               for ((i, element) in elements.withIndex())
-                       require(element.any { it != '.' }) {
-                               "Cannot have elements . or .. in path, got $element at index $i in path /${elements.joinToString(separator = "/")}"
-                       }
-       }
-       
-       constructor(path: String) : this(path.split('/').filterNot(String::isEmpty))
-       
-       val name: String
-               get() = elements.lastOrNull().orEmpty()
-       
-       val isRoot: Boolean
-               get() = elements.isEmpty()
-       
-       operator fun div(element: String) = this / element.split('/')
-       operator fun div(elementCollection: Iterable<String>) = StoragePath(elements + elementCollection.filterNot(String::isEmpty))
-       
-       override fun toString(): String {
-               return elements.joinToString(separator = "/")
-       }
-       
-       companion object {
-               val Root = StoragePath(emptyList())
-               
-               val articleDir = Root / "lore"
-               val assetDir = Root / "assets"
-               val templateDir = Root / "tpl"
-               val jsonDocDir = Root / "data"
-               val scriptDir = Root / "funcs"
-               val april1Dir = Root / "funny"
-       }
-}
-
-enum class StoredFileType {
-       DIRECTORY,
-       FILE,
-}
-
-data class StoredFileEntry(val name: String, val type: StoredFileType)
-
-data class StoredFileStats(
-       val updated: Instant,
-       val size: Long,
-)
-
-interface FileStorage {
-       suspend fun prepare() = Unit
-       
-       suspend fun getType(path: StoragePath): StoredFileType?
-       
-       suspend fun createDir(dir: StoragePath): Boolean
-       
-       suspend fun listDir(dir: StoragePath): List<StoredFileEntry>?
-       
-       suspend fun deleteDir(dir: StoragePath): Boolean
-       
-       suspend fun statFile(path: StoragePath): StoredFileStats?
-       
-       suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean
-       
-       suspend fun readFile(path: StoragePath): ByteArray?
-       
-       suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean
-       
-       suspend fun eraseFile(path: StoragePath): Boolean
-       
-       suspend fun performMaintenance() = Unit
-       
-       companion object {
-               lateinit var instance: FileStorage
-                       private set
-               
-               private val maintenanceScope = CoroutineScope(SupervisorJob() + CoroutineName("file-storage-maintenance"))
-               
-               suspend operator fun invoke(config: FileStorageConfig) = when (config) {
-                       is FileStorageConfig.Flat -> FlatFileStorage(File(config.baseDir))
-                       FileStorageConfig.GridFs -> GridFsStorage(
-                               DocumentTable(),
-                               ConnectionHolder.getBucket()
-                       )
-               }.apply { prepare() }
-               
-               private suspend fun configure() {
-                       instance = when (val storage = Configuration.Current.storage) {
-                               is FileStorageConfig.Flat -> FlatFileStorage(File(storage.baseDir))
-                               FileStorageConfig.GridFs -> GridFsStorage(
-                                       DocumentTable(),
-                                       ConnectionHolder.getBucket()
-                               )
-                       }.apply { prepare() }
-                       
-                       maintenanceScope.launch {
-                               while (true) {
-                                       launch(SupervisorJob(currentCoroutineContext().job)) {
-                                               instance.performMaintenance()
-                                       }
-                                       
-                                       delay(8.hours)
-                               }
-                       }
-               }
-               
-               fun initialize() = runBlocking { configure() }
-       }
-}
-
-private class FlatFileStorage(val root: File) : FileStorage {
-       private fun resolveFile(path: StoragePath) = if (path.isRoot) root else root.combineSafe(path.toString())
-       
-       private fun renderEntry(file: File) = StoredFileEntry(file.name, if (file.isFile) StoredFileType.FILE else StoredFileType.DIRECTORY)
-       
-       private fun createDir(file: File): Boolean {
-               if (file.isFile) return false
-               if (file.isDirectory) return true
-               
-               if (!file.parentFile.exists())
-                       if (!createDir(file.parentFile))
-                               return false
-               
-               file.mkdir()
-               return true
-       }
-       
-       private fun createFile(file: File): Boolean {
-               if (!file.exists()) {
-                       val containingDir = file.parentFile
-                       if (!containingDir.isDirectory)
-                               if (!createDir(containingDir)) return false
-               }
-               
-               return true
-       }
-       
-       override suspend fun prepare() {
-               withContext(Dispatchers.IO) {
-                       if (root.exists() && !root.isDirectory)
-                               root.delete()
-                       root.mkdirs()
-               }
-       }
-       
-       override suspend fun getType(path: StoragePath): StoredFileType? {
-               val file = resolveFile(path)
-               return if (file.isFile)
-                       StoredFileType.FILE
-               else if (file.isDirectory)
-                       StoredFileType.DIRECTORY
-               else null
-       }
-       
-       override suspend fun createDir(dir: StoragePath): Boolean {
-               return withContext(Dispatchers.IO) { createDir(resolveFile(dir)) }
-       }
-       
-       override suspend fun listDir(dir: StoragePath): List<StoredFileEntry>? {
-               return withContext(Dispatchers.IO) { resolveFile(dir).listFiles()?.map { renderEntry(it) } }
-       }
-       
-       override suspend fun deleteDir(dir: StoragePath): Boolean {
-               if (dir.isRoot) return false
-               val file = resolveFile(dir)
-               if (!file.isDirectory) return true
-               return withContext(Dispatchers.IO) { file.deleteRecursively() }
-       }
-       
-       override suspend fun statFile(path: StoragePath): StoredFileStats? {
-               val file = resolveFile(path)
-               if (!file.isFile) return null
-               
-               return StoredFileStats(Instant.ofEpochMilli(file.lastModified()), file.length())
-       }
-       
-       override suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean {
-               val file = resolveFile(path)
-               
-               return withContext(Dispatchers.IO) {
-                       if (createFile(file)) {
-                               file.writeBytes(content)
-                               true
-                       } else false
-               }
-       }
-       
-       override suspend fun readFile(path: StoragePath): ByteArray? {
-               val file = resolveFile(path)
-               if (!file.isFile) return null
-               
-               return withContext(Dispatchers.IO) {
-                       file.readBytes()
-               }
-       }
-       
-       override suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean {
-               val sourceFile = resolveFile(source)
-               val targetFile = resolveFile(target)
-               
-               if (!sourceFile.isFile) return false
-               
-               withContext(Dispatchers.IO) {
-                       sourceFile.copyTo(targetFile, overwrite = true)
-               }
-               
-               return true
-       }
-       
-       override suspend fun eraseFile(path: StoragePath): Boolean {
-               val file = resolveFile(path)
-               if (!file.isFile) return true
-               return withContext(Dispatchers.IO) { file.delete() }
-       }
-}
-
-@Serializable
-private data class GridFsEntry(
-       @SerialName(MONGODB_ID_KEY)
-       override val id: Id<GridFsEntry>,
-       val path: String,
-       val file: @Serializable(with = ObjectIdSerializer::class) ObjectId,
-       val created: @Serializable(with = InstantSerializer::class) Instant,
-       val updated: @Serializable(with = InstantSerializer::class) Instant,
-) : DataDocument<GridFsEntry>
-
-private class GridFsStorage(val table: DocumentTable<GridFsEntry>, val bucket: GridFSBucket) : FileStorage {
-       private fun toExactPath(path: StoragePath) = path.elements.joinToString(separator = "") { "/$it" }
-       private fun toPrefixPath(path: StoragePath) = "${toExactPath(path)}/"
-       
-       private suspend fun testExact(path: StoragePath) = table.number(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) > 0L
-       private suspend fun getExact(path: StoragePath) = table.locate(Filters.eq(GridFsEntry::path.serialName, toExactPath(path)))
-       private suspend fun deleteExact(path: StoragePath) = table.remove(Filters.eq(GridFsEntry::path.serialName, toExactPath(path))) > 0L
-       private suspend fun updateExact(path: StoragePath, newFile: ObjectId) {
-               val now = Instant.now()
-               val exactPath = toExactPath(path)
-               
-               table.change(
-                       Filters.eq(GridFsEntry::path.serialName, exactPath),
-                       Updates.combine(
-                               Updates.set(GridFsEntry::file.serialName, newFile),
-                               Updates.set(GridFsEntry::updated.serialName, now),
-                               Updates.setOnInsert(GridFsEntry::created.serialName, now),
-                               Updates.setOnInsert(GridFsEntry::id.serialName, Id<GridFsEntry>())
-                       )
-               )
-       }
-       
-       private suspend fun countPrefix(path: StoragePath) = table.number(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}"))
-       private suspend fun getPrefix(path: StoragePath) = table.filter(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}"))
-       private suspend fun deletePrefix(path: StoragePath) = table.remove(Filters.regex(GridFsEntry::path.serialName, "^${Regex.fromLiteral(toPrefixPath(path))}"))
-       private suspend fun createPrefix(path: StoragePath) {
-               val now = Instant.now()
-               val keepPath = path / GRID_FS_KEEP
-               
-               table.change(
-                       Filters.eq(GridFsEntry::path.serialName, toExactPath(keepPath)),
-                       Updates.combine(
-                               Updates.setOnInsert(GridFsEntry::id.serialName, Id<GridFsEntry>()),
-                               Updates.setOnInsert(GridFsEntry::file.serialName, emptyFileId),
-                               Updates.setOnInsert(GridFsEntry::created.serialName, now),
-                               Updates.setOnInsert(GridFsEntry::updated.serialName, now),
-                       )
-               )
-       }
-       
-       private suspend fun getSuffix(fullPath: StoragePath, forDir: Boolean = false) = try {
-               val pathParts = fullPath.elements
-               
-               coroutineScope {
-                       val indices = (if (forDir) 0 else 1)..pathParts.lastIndex
-                       
-                       indices.map { index ->
-                               val path = StoragePath(pathParts.dropLast(index))
-                               launch {
-                                       if (testExact(path)) throw FileAlreadyExistsException(path.toString())
-                               }
-                       }
-               }
-               
-               null
-       } catch (ex: FileAlreadyExistsException) {
-               StoragePath(ex.file)
-       }
-       
-       private lateinit var emptyFileId: ObjectId
-       
-       private suspend fun getOrCreateEmptyFile(): ObjectId {
-               bucket
-                       .find(Filters.and(Filters.eq("length", 0), Filters.eq("filename", GRID_FS_KEEP)))
-                       .awaitFirstOrNull()
-                       ?.objectId
-                       ?.let { return it }
-               
-               val bytesPublisher = { ByteBuffer.allocate(0) }
-                       .asFlow()
-                       .asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO)
-               
-               return bucket.uploadFromPublisher(GRID_FS_KEEP, bytesPublisher).awaitFirst()
-       }
-       
-       override suspend fun prepare() {
-               table.unique(GridFsEntry::path)
-               emptyFileId = getOrCreateEmptyFile()
-       }
-       
-       override suspend fun getType(path: StoragePath): StoredFileType? {
-               return if (getExact(path) != null)
-                       StoredFileType.FILE
-               else if (countPrefix(path) > 0)
-                       StoredFileType.DIRECTORY
-               else null
-       }
-       
-       override suspend fun createDir(dir: StoragePath): Boolean {
-               if (dir.isRoot) return true
-               if (getSuffix(dir, forDir = true) != null) return false
-               
-               createPrefix(dir)
-               return true
-       }
-       
-       override suspend fun listDir(dir: StoragePath): List<StoredFileEntry>? {
-               val prefixPath = toPrefixPath(dir)
-               val allEntries = getPrefix(dir).map {
-                       val subPath = it.path.removePrefix(prefixPath)
-                       if (subPath.contains('/'))
-                               StoredFileEntry(subPath.substringBefore('/'), StoredFileType.DIRECTORY)
-                       else
-                               StoredFileEntry(subPath, StoredFileType.FILE)
-               }.toList().distinctBy { it.name }
-               
-               if (allEntries.isEmpty())
-                       return null
-               
-               return allEntries.filter { it.name != GRID_FS_KEEP }
-       }
-       
-       override suspend fun deleteDir(dir: StoragePath): Boolean {
-               if (dir.isRoot) return false
-               deletePrefix(dir)
-               return true
-       }
-       
-       override suspend fun statFile(path: StoragePath): StoredFileStats? {
-               if (path.isRoot) return null
-               val file = getExact(path) ?: return null
-               val gridFsFile = bucket.find(Filters.eq(MONGODB_ID_KEY, file.file)).awaitFirst()
-               return StoredFileStats(file.updated, gridFsFile.length)
-       }
-       
-       override suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean {
-               if (path.isRoot) return false
-               if (getSuffix(path) != null) return false
-               if (countPrefix(path) > 0) return false
-               
-               val bytesPublisher = flow {
-                       emit(ByteBuffer.wrap(content))
-               }.asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO)
-               
-               val newId = bucket.uploadFromPublisher(path.elements.last(), bytesPublisher).awaitFirst()
-               updateExact(path, newId)
-               return true
-       }
-       
-       override suspend fun readFile(path: StoragePath): ByteArray? {
-               if (path.isRoot) return null
-               val file = getExact(path) ?: return null
-               val gridFsId = file.file
-               
-               return ByteArrayOutputStream().also { content ->
-                       bucket.downloadToPublisher(gridFsId).asFlow().collect { buffer ->
-                               val array = buffer.slice().moveToByteArray()
-                               withContext(Dispatchers.IO) { content.write(array) }
-                       }
-               }.toByteArray()
-       }
-       
-       override suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean {
-               if (source.isRoot || target.isRoot) return false
-               if (getSuffix(target) != null) return false
-               val sourceFile = getExact(source) ?: return false
-               updateExact(target, sourceFile.file)
-               return true
-       }
-       
-       override suspend fun eraseFile(path: StoragePath): Boolean {
-               if (path.isRoot) return false
-               return deleteExact(path)
-       }
-       
-       override suspend fun performMaintenance() {
-               val allUsedIds = table.all().map { it.file }.toSet()
-               val unusedFiles = bucket.find(
-                       Filters.and(
-                               Filters.nin(MONGODB_ID_KEY, allUsedIds),
-                               Filters.ne("filename", GRID_FS_KEEP)
-                       )
-               ).asFlow().map { it.objectId }.toSet()
-               
-               coroutineScope {
-                       unusedFiles.map { unusedFile ->
-                               launch {
-                                       bucket.delete(unusedFile).awaitFirst()
-                               }
-                       }.joinAll()
-               }
-       }
-       
-       companion object {
-               private const val GRID_FS_KEEP = ".grid-fs-keep"
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/data_flow.kt b/src/jvmMain/kotlin/info/mechyrdia/data/data_flow.kt
deleted file mode 100644 (file)
index 80f541c..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-package info.mechyrdia.data
-
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.FlowCollector
-
-fun <T> Flow<T>.distinct(): Flow<T> = DistinctSetFlow(this) { it }
-fun <T, K> Flow<T>.distinctBy(keySelector: (T) -> K): Flow<T> = DistinctSetFlow(this, keySelector)
-
-private class DistinctSetFlow<T, K>(
-       private val upstream: Flow<T>,
-       private val keySelector: (T) -> K
-) : Flow<T> {
-       override suspend fun collect(collector: FlowCollector<T>) {
-               val previousKeys = mutableSetOf<K>()
-               upstream.collect { value ->
-                       val key = keySelector(value)
-                       if (previousKeys.add(key))
-                               collector.emit(value)
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/data_utils.kt b/src/jvmMain/kotlin/info/mechyrdia/data/data_utils.kt
deleted file mode 100644 (file)
index 274b065..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-package info.mechyrdia.data
-
-import kotlinx.serialization.SerialName
-import kotlin.reflect.KProperty
-import kotlin.reflect.full.findAnnotations
-
-suspend inline fun <T : DataDocument<T>> DocumentTable<T>.getOrPut(id: Id<T>, defaultValue: () -> T): T {
-       val value = get(id)
-       return if (value == null) {
-               val answer = defaultValue()
-               if (answer.id != id) {
-                       throw IllegalArgumentException("Default value $answer has different Id than provided: $id")
-               }
-               put(answer)
-               answer
-       } else {
-               value
-       }
-}
-
-val <T> KProperty<T>.serialName: String
-       get() = findAnnotations(SerialName::class).singleOrNull()?.value ?: name
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt b/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt
deleted file mode 100644 (file)
index 09e1e03..0000000
+++ /dev/null
@@ -1,196 +0,0 @@
-package info.mechyrdia.data
-
-import info.mechyrdia.OwnerNationId
-import info.mechyrdia.lore.*
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.href
-import info.mechyrdia.route.installCsrfToken
-import io.ktor.server.application.*
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
-import kotlinx.html.*
-import java.time.Instant
-
-data class CommentRenderData(
-       val id: Id<Comment>,
-       
-       val submittedBy: NationData,
-       val submittedIn: List<String>,
-       val submittedAt: Instant,
-       
-       val submittedInTitle: String,
-       
-       val numEdits: Int,
-       val lastEdit: Instant?,
-       
-       val contentsRaw: String,
-       val contentsHtml: TagConsumer<*>.() -> Any?,
-       
-       val replyLinks: List<Id<Comment>>,
-) {
-       companion object {
-               suspend operator fun invoke(comments: List<Comment>, nations: MutableMap<Id<NationData>, NationData> = mutableMapOf()): List<CommentRenderData> {
-                       return coroutineScope {
-                               comments.map { comment ->
-                                       async {
-                                               val nationDataAsync = async { nations.getNation(comment.submittedBy) }
-                                               val pageTitleAsync = async { (StoragePath.articleDir / comment.submittedIn.split('/')).toFriendlyPathTitle() }
-                                               val htmlResult = comment.contents.parseAs(ParserTree::toCommentHtml)
-                                               
-                                               CommentRenderData(
-                                                       id = comment.id,
-                                                       submittedBy = nationDataAsync.await(),
-                                                       submittedIn = comment.submittedIn.split('/'),
-                                                       submittedAt = comment.submittedAt,
-                                                       submittedInTitle = pageTitleAsync.await(),
-                                                       numEdits = comment.numEdits,
-                                                       lastEdit = comment.lastEdit,
-                                                       contentsRaw = comment.contents,
-                                                       contentsHtml = htmlResult,
-                                                       replyLinks = CommentReplyLink.getReplies(comment.id),
-                                               )
-                                       }
-                               }.awaitAll()
-                       }
-               }
-       }
-}
-
-context(ApplicationCall)
-fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData>?, viewingUserPage: Boolean = false) {
-       if (comment.submittedBy.isBanned && !viewingUserPage && loggedInAs != comment.submittedBy.id && loggedInAs != OwnerNationId)
-               return
-       
-       if (viewingUserPage)
-               p {
-                       style = "font-size:1.5em;margin-top:2.5em"
-                       +"On factbook "
-                       a(href = href(Root.LorePage(comment.submittedIn))) {
-                               +comment.submittedInTitle
-                       }
-               }
-       
-       div(classes = "comment-box") {
-               id = "comment-${comment.id}"
-               div(classes = "comment-author") {
-                       img(src = comment.submittedBy.flag, alt = "Flag of ${comment.submittedBy.name}", classes = "flag-icon")
-                       span(classes = "author-name") {
-                               +Entities.nbsp
-                               a(href = href(Root.User.ById(comment.submittedBy.id))) {
-                                       +comment.submittedBy.name
-                               }
-                       }
-                       span(classes = "posted-at") {
-                               dateTime(comment.submittedAt)
-                       }
-               }
-               
-               div(classes = "comment") {
-                       +comment.contentsHtml
-                       comment.lastEdit?.let { lastEdit ->
-                               p {
-                                       style = "font-size:0.8em"
-                                       +"Edited ${comment.numEdits} ${comment.numEdits.pluralize("time")}, last edited at "
-                                       dateTime(lastEdit)
-                               }
-                       }
-                       p {
-                               style = "font-size:0.8em"
-                               a(href = href(Root.Comments.ViewPage(comment.id))) {
-                                       +"Permalink"
-                               }
-                               +Entities.nbsp
-                               a(href = "#", classes = "copy-text") {
-                                       attributes["data-text"] = "https://mechyrdia.info${href(Root.Comments.ViewPage(comment.id))}"
-                                       +"(Copy)"
-                               }
-                               
-                               if (loggedInAs != null) {
-                                       +Entities.nbsp
-                                       +"\u2022"
-                                       +Entities.nbsp
-                                       a(href = "#", classes = "copy-text") {
-                                               attributes["data-text"] = "[reply]${comment.id}[/reply]"
-                                               +"Reply"
-                                       }
-                                       
-                                       +Entities.nbsp
-                                       +"\u2022"
-                                       +Entities.nbsp
-                                       a(href = "#", classes = "copy-text") {
-                                               attributes["data-text"] = "[quote]${comment.contentsRaw}[/quote][reply]${comment.id}[/reply]"
-                                               +"Quote Reply"
-                                       }
-                               }
-                               
-                               if (loggedInAs == comment.submittedBy.id) {
-                                       +Entities.nbsp
-                                       +"\u2022"
-                                       +Entities.nbsp
-                                       a(href = "#", classes = "comment-edit-link") {
-                                               attributes["data-edit-id"] = "comment-edit-box-${comment.id}"
-                                               +"Edit"
-                                       }
-                               }
-                               
-                               if (loggedInAs == comment.submittedBy.id || loggedInAs == OwnerNationId) {
-                                       +Entities.nbsp
-                                       +"\u2022"
-                                       +Entities.nbsp
-                                       a(href = href(Root.Comments.DeleteConfirmPage(comment.id)), classes = "comment-delete-link") {
-                                               +"Delete"
-                                       }
-                               }
-                       }
-                       if (comment.replyLinks.isNotEmpty())
-                               p {
-                                       style = "font-size:0.8em"
-                                       +"Replies:"
-                                       for (reply in comment.replyLinks) {
-                                               +" "
-                                               a(href = href(Root.Comments.ViewPage(reply))) {
-                                                       +">>$reply"
-                                               }
-                                       }
-                               }
-               }
-       }
-       
-       if (loggedInAs == comment.submittedBy.id) {
-               val formPath = href(Root.Comments.EditPost(comment.id))
-               form(action = formPath, method = FormMethod.post, classes = "comment-input comment-edit-box") {
-                       id = "comment-edit-box-${comment.id}"
-                       div(classes = "comment-preview")
-                       textArea(classes = "comment-markup") {
-                               name = "comment"
-                               +comment.contentsRaw
-                       }
-                       installCsrfToken()
-                       submitInput { value = "Edit Comment" }
-                       button(classes = "comment-cancel-edit evil") {
-                               +"Cancel Editing"
-                       }
-               }
-       }
-}
-
-context(ApplicationCall)
-fun FlowContent.commentInput(pagePathParts: List<String>, commentingAs: NationData?) {
-       if (commentingAs == null) {
-               p {
-                       a(href = href(Root.Auth.LoginPage())) { +"Log in" }
-                       +" to comment"
-               }
-               return
-       }
-       
-       form(action = href(Root.Comments.NewPost(path = pagePathParts)), method = FormMethod.post, classes = "comment-input") {
-               div(classes = "comment-preview")
-               textArea(classes = "comment-markup") {
-                       name = "comment"
-               }
-               installCsrfToken()
-               submitInput { value = "Submit Comment" }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt
deleted file mode 100644 (file)
index e19bb69..0000000
+++ /dev/null
@@ -1,513 +0,0 @@
-package info.mechyrdia.data
-
-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
-import io.ktor.server.application.*
-import kotlinx.coroutines.flow.filterNot
-import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.flow.toList
-import kotlinx.html.*
-import java.time.Instant
-
-suspend fun ApplicationCall.recentCommentsPage(limit: Int?): HTML.() -> Unit {
-       limit ?: redirectHref(Root.Comments.RecentPage(10))
-       
-       val currNation = currentNation()
-       
-       val validLimits = listOf(10, 20, 50, 80, 100)
-       
-       if (limit !in validLimits)
-               redirectHref(Root.Comments.RecentPage(limit = 10))
-       
-       val comments = CommentRenderData(
-               Comment.Table
-                       .sorted(Sorts.descending(Comment::submittedAt.serialName))
-                       .filterNot { comment ->
-                               comment.submittedBy != currNation?.id && NationData.get(comment.submittedBy).isBanned
-                       }
-                       .take(limit)
-                       .toList(),
-               nationCache
-       )
-       
-       return page("Recent Comments", standardNavBar()) {
-               section {
-                       h1 { +"Recent Comments" }
-                       
-                       p {
-                               +"Number of comments to view: "
-                               for ((i, validLimit) in validLimits.withIndex()) {
-                                       if (i != 0)
-                                               +Entities.nbsp
-                                       
-                                       if (limit == validLimit)
-                                               strong {
-                                                       +"$validLimit"
-                                               }
-                                       else
-                                               a(href = href(Root.Comments.RecentPage(limit = validLimit))) {
-                                                       +"$validLimit"
-                                               }
-                               }
-                       }
-                       
-                       for (comment in comments)
-                               commentBox(comment, currNation?.id, viewingUserPage = true)
-               }
-       }
-}
-
-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"))))
-       
-       if (contents.isBlank())
-               redirectHref(Root.LorePage(pagePathParts, root = Root(error = "Comments may not be blank")))
-       
-       val now = Instant.now()
-       val comment = Comment(
-               id = Id(),
-               submittedBy = loggedInAs.id,
-               submittedIn = pagePathParts.joinToString("/"),
-               submittedAt = now,
-               
-               numEdits = 0,
-               lastEdit = null,
-               
-               contents = contents
-       )
-       
-       Comment.Table.put(comment)
-       CommentReplyLink.updateComment(comment.id, getReplies(contents), now)
-       
-       redirectHref(Root.LorePage(pagePathParts), hash = "comment-${comment.id}")
-}
-
-suspend fun ApplicationCall.viewCommentRoute(commentId: Id<Comment>): Nothing {
-       val comment = Comment.Table.get(commentId)!!
-       
-       val currentNation = currentNation()
-       val submitter = nationCache.getNation(comment.submittedBy)
-       
-       if (submitter.isBanned && currentNation?.id != comment.submittedBy && currentNation?.id != OwnerNationId)
-               throw NoSuchElementException("Shadowbanned comment")
-       
-       val pagePathParts = comment.submittedIn.split('/')
-       val errorMessage = attributes.getOrNull(ErrorMessageAttributeKey)
-       redirectHref(Root.LorePage(pagePathParts, root = Root(errorMessage)), 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"))))
-       
-       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"))))
-       
-       // Check for null edits, i.e. edits that don't change anything
-       if (newContents == oldComment.contents)
-               redirectHref(Root.Comments.ViewPage(oldComment.id))
-       
-       val now = Instant.now()
-       val newComment = oldComment.copy(
-               numEdits = oldComment.numEdits + 1,
-               lastEdit = now,
-               contents = newContents
-       )
-       
-       Comment.Table.put(newComment)
-       CommentReplyLink.updateComment(commentId, getReplies(newContents), now)
-       
-       redirectHref(Root.Comments.ViewPage(oldComment.id))
-}
-
-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 comment = Comment.Table.get(commentId)!!
-       
-       if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
-               throw ForbiddenException("Illegal attempt by ${currNation.id} to delete comment by ${comment.submittedBy}")
-       
-       return currNation to comment
-}
-
-suspend fun ApplicationCall.deleteCommentPage(commentId: Id<Comment>): HTML.() -> Unit {
-       val (currNation, comment) = getCommentForDeletion(commentId)
-       
-       val commentDisplay = CommentRenderData(listOf(comment), nationCache).single()
-       
-       return page("Confirm Deletion of Comment", standardNavBar()) {
-               section {
-                       p {
-                               +"Are you sure you want to delete this comment? "
-                               strong { +"It will be gone forever!" }
-                       }
-                       
-                       commentBox(commentDisplay, currNation.id)
-                       
-                       form(method = FormMethod.get, action = href(Root.Comments.ViewPage(comment.id))) {
-                               submitInput { value = "No, take me back" }
-                       }
-                       form(method = FormMethod.post, action = href(Root.Comments.DeleteConfirmPost(comment.id))) {
-                               installCsrfToken()
-                               submitInput(classes = "evil") { value = "Yes, delete it" }
-                       }
-               }
-       }
-}
-
-suspend fun ApplicationCall.deleteCommentRoute(commentId: Id<Comment>): Nothing {
-       val (_, comment) = getCommentForDeletion(commentId)
-       
-       Comment.Table.del(comment.id)
-       CommentReplyLink.deleteComment(comment.id)
-       
-       val pagePathParts = comment.submittedIn.split('/')
-       redirectHref(Root.LorePage(pagePathParts), hash = "comments")
-}
-
-suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commenting Help", standardNavBar()) {
-       section {
-               h1 { +"Commenting Help" }
-               p { +"Comments on this repository support a subset of the BBCode used in factbook markup." }
-               p { +"The following tags are supported:" }
-               table {
-                       thead {
-                               tr {
-                                       th { +"Tag" }
-                                       th { +"Purpose" }
-                               }
-                       }
-                       tbody {
-                               tr {
-                                       td { +"[b]Text goes here[/b]" }
-                                       td {
-                                               span {
-                                                       style = "font-weight:bold"
-                                                       +"Emboldens"
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[i]Text goes here[/i]" }
-                                       td {
-                                               span {
-                                                       style = "font-style:italic"
-                                                       +"Italicizes"
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[u]Text goes here[/u]" }
-                                       td {
-                                               span {
-                                                       style = "text-decoration: underline"
-                                                       +"Underlines"
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[s]Text goes here[/s]" }
-                                       td {
-                                               span {
-                                                       style = "text-decoration: line-through"
-                                                       +"Strikes out"
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[sup]Text goes here[/sup]" }
-                                       td {
-                                               sup {
-                                                       +"Superscripts"
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[sub]Text goes here[/sub]" }
-                                       td {
-                                               sub {
-                                                       +"Subscripts"
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[color=#CC8844]Text goes here[/sub]" }
-                                       td {
-                                               span {
-                                                       style = "color:#CC8844"
-                                                       +"Colors"
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[ipa]Text goes here[/ipa]" }
-                                       td {
-                                               span {
-                                                       style = "font-family:DejaVu Sans"
-                                                       +"Applies IPA font to "
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[code]Text goes here[/code]" }
-                                       td {
-                                               span {
-                                                       style = "font-family:JetBrains Mono"
-                                                       +"Applies code font to "
-                                               }
-                                               +" text"
-                                       }
-                               }
-                               tr {
-                                       td { +"[align=(left, center, right, or justify)]Text goes here[/align]" }
-                                       td {
-                                               +"Aligns text on the left, center, right, or justified"
-                                       }
-                               }
-                               tr {
-                                       td { +"e.g. [align=center]Text goes here[/align]" }
-                                       td {
-                                               div {
-                                                       style = "text-align: center"
-                                                       +"Center-aligns text"
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[aside=(left or right)]Text goes here[/aside]" }
-                                       td {
-                                               +"Creates a floating block to the side, on either the left or the right"
-                                       }
-                               }
-                               tr {
-                                       td { +"[ul][li]List items go here[/li]... [/ul]" }
-                                       td {
-                                               +"Creates a bullet list, e.g."
-                                               ul {
-                                                       li { +"Item" }
-                                                       li { +"The cooler item" }
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[ol][li]List items go here[/li]... [/ol]" }
-                                       td {
-                                               +"Creates a numbered list, e.g."
-                                               ol {
-                                                       li { +"First item" }
-                                                       li { +"Second item" }
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[table](table rows go here...)[/table]" }
-                                       td {
-                                               +"The root element of a table"
-                                       }
-                               }
-                               tr {
-                                       td { +"[tr](table cells go here...)[/tr]" }
-                                       td {
-                                               +"A row of a table"
-                                       }
-                               }
-                               tr {
-                                       td { +"[th]Text goes here[/th]" }
-                                       td {
-                                               +"A heading cell of a table"
-                                       }
-                               }
-                               tr {
-                                       td { +"[td]Text goes here[/td]" }
-                                       td {
-                                               +"A data cell of a table"
-                                       }
-                               }
-                       }
-               }
-               val tableDemoMarkup =
-                       """
-                               |[table]
-                               |[tr]
-                               |[th=2x2][i]ab[/i][sup]-1[/sup] mod 10[/th]
-                               |[th=10][i]a[/i][/th]
-                               |[/tr]
-                               |[tr]
-                               |[th]0[/th]
-                               |[th]1[/th]
-                               |[th]2[/th]
-                               |[th]3[/th]
-                               |[th]4[/th]
-                               |[th]5[/th]
-                               |[th]6[/th]
-                               |[th]7[/th]
-                               |[th]8[/th]
-                               |[th]9[/th]
-                               |[/tr]
-                               |[tr]
-                               |[th=x4][i]b[/i][/th]
-                               |[th]1[/th]
-                               |[td]0[/td]
-                               |[td]1[/td]
-                               |[td]2[/td]
-                               |[td]3[/td]
-                               |[td]4[/td]
-                               |[td]5[/td]
-                               |[td]6[/td]
-                               |[td]7[/td]
-                               |[td]8[/td]
-                               |[td]9[/td]
-                               |[/tr]
-                               |[tr]
-                               |[th]3[/th]
-                               |[td]0[/td]
-                               |[td]7[/td]
-                               |[td]4[/td]
-                               |[td]1[/td]
-                               |[td]8[/td]
-                               |[td]5[/td]
-                               |[td]2[/td]
-                               |[td]9[/td]
-                               |[td]6[/td]
-                               |[td]3[/td]
-                               |[/tr]
-                               |[tr]
-                               |[th]7[/th]
-                               |[td]0[/td]
-                               |[td]3[/td]
-                               |[td]6[/td]
-                               |[td]9[/td]
-                               |[td]2[/td]
-                               |[td]5[/td]
-                               |[td]8[/td]
-                               |[td]1[/td]
-                               |[td]4[/td]
-                               |[td]7[/td]
-                               |[/tr]
-                               |[tr]
-                               |[th]9[/th]
-                               |[td]0[/td]
-                               |[td]9[/td]
-                               |[td]8[/td]
-                               |[td]7[/td]
-                               |[td]6[/td]
-                               |[td]5[/td]
-                               |[td]4[/td]
-                               |[td]3[/td]
-                               |[td]2[/td]
-                               |[td]1[/td]
-                               |[/tr]
-                               |[/table]
-                       """.trimMargin()
-               val tableDemoHtml = tableDemoMarkup.parseAs(ParserTree::toCommentHtml)
-               p {
-                       +"Table cells in this custom BBCode markup also support row-spans and column-spans, even at the same time:"
-               }
-               pre { +tableDemoMarkup }
-               +tableDemoHtml
-               p {
-                       +"The format goes as [td=(width)x(height)] or [th=(width)x(height)]. If one parameter is omitted (assumed to be 1), then the format can be [td=(width)] or [td=x(height)]"
-               }
-               table {
-                       thead {
-                               tr {
-                                       th { +"Tag" }
-                                       th { +"Purpose" }
-                               }
-                       }
-                       tbody {
-                               tr {
-                                       td { +"[url=https://google.com/]Text goes here[/url]" }
-                                       td {
-                                               +"Creates an "
-                                               a(href = "https://google.com/") {
-                                                       rel = "nofollow"
-                                                       +"HTML link"
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[imgbb=256x256]Lns12z1/robert-sparr.png[/imgbb]" }
-                                       td {
-                                               p {
-                                                       +"Creates an embedded image:"
-                                                       br
-                                                       img(src = "https://i.ibb.co/Lns12z1/robert-sparr.png") {
-                                                               style = getImageSizeStyleValue(256, 256)
-                                                       }
-                                                       br
-                                                       +"The tag param controls the width and height, much like a table cell. The size unit is viewport-responsive and has no correlation with pixels."
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[reply](comment id)[/reply]" }
-                                       td { +"Creates a reply link to a comment" }
-                               }
-                               tr {
-                                       td { +"[quote]Quoted text[/quote]" }
-                                       td {
-                                               +"Creates a "
-                                               blockQuote {
-                                                       +"block-level quote"
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[lang=tylan]Rheagda Tulasra[/lang]" }
-                                       td {
-                                               +"Writes text in the Tylan alphabet: "
-                                               span(classes = "lang-tylan") {
-                                                       +TylanAlphabetFont.tylanToFontAlphabet("rheagda tulasra")
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[lang=thedish]Theodisc Rasda[/lang]" }
-                                       td {
-                                               +"Writes text in the Thedish alphabet: "
-                                               span(classes = "lang-thedish") {
-                                                       +"Theodisc Rasda"
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[lang=pokhval]Pokhvalsko Jaargo[/lang]" }
-                                       td {
-                                               +"Writes text in the Pokhwalish alphabet: "
-                                               span(classes = "lang-pokhwal") {
-                                                       +PokhwalishAlphabetFont.pokhwalToFontAlphabet("pokhvalsqo jaargo")
-                                               }
-                                       }
-                               }
-                               tr {
-                                       td { +"[lang=kishari]KyÅŸary lanur[/lang]" }
-                                       td {
-                                               +"Writes text in the Kishari alphabet: "
-                                               span(classes = "lang-kishari") {
-                                                       +"kyÅŸary lanur"
-                                               }
-                                       }
-                               }
-                       }
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt
deleted file mode 100644 (file)
index 3c672a9..0000000
+++ /dev/null
@@ -1,401 +0,0 @@
-package info.mechyrdia.data
-
-import info.mechyrdia.auth.PageDoNotCacheAttributeKey
-import info.mechyrdia.lore.adminPage
-import info.mechyrdia.lore.dateTime
-import info.mechyrdia.lore.redirectHref
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.href
-import info.mechyrdia.route.installCsrfToken
-import io.ktor.http.*
-import io.ktor.http.content.*
-import io.ktor.server.application.*
-import io.ktor.server.html.*
-import io.ktor.server.plugins.*
-import io.ktor.server.response.*
-import io.ktor.utils.io.jvm.javaio.*
-import kotlinx.coroutines.*
-import kotlinx.html.*
-
-private sealed class TreeNode {
-       data class FileNode(val stats: StoredFileStats) : TreeNode()
-       data class DirNode(val children: Map<String, TreeNode>) : TreeNode()
-}
-
-private suspend fun fileTree(path: StoragePath): TreeNode? {
-       return FileStorage.instance.statFile(path)?.let {
-               TreeNode.FileNode(it)
-       } ?: coroutineScope {
-               FileStorage.instance.listDir(path)?.map { entry ->
-                       async {
-                               fileTree(path / entry.name)?.let { entry.name to it }
-                       }
-               }?.awaitAll()
-                       ?.filterNotNull()
-                       ?.toMap()
-                       ?.let { TreeNode.DirNode(it) }
-       }
-}
-
-context(ApplicationCall)
-private fun UL.render(path: StoragePath, childNodes: Map<String, TreeNode>) {
-       val sortedChildren = childNodes.toList().sortedBy { it.first }.sortedBy {
-               when (it.second) {
-                       is TreeNode.FileNode -> 1
-                       is TreeNode.DirNode -> 0
-               }
-       }
-       
-       for ((name, child) in sortedChildren)
-               render(path / name, child)
-       
-       li {
-               style = "list-style:none"
-               
-               p {
-                       form(action = href(Root.Admin.Vfs.Upload(path.elements)), method = FormMethod.post, encType = FormEncType.multipartFormData) {
-                               installCsrfToken()
-                               label {
-                                       fileInput(name = "uploaded")
-                                       +"Upload File"
-                               }
-                               submitInput()
-                       }
-               }
-               
-               p {
-                       form(action = href(Root.Admin.Vfs.MkDir(path.elements)), method = FormMethod.post) {
-                               installCsrfToken()
-                               textInput {
-                                       placeholder = "new-dir"
-                               }
-                               +Entities.nbsp
-                               submitInput {
-                                       value = "Make Directory"
-                               }
-                       }
-               }
-               
-               if (!path.isRoot)
-                       p {
-                               form(action = href(Root.Admin.Vfs.RmDirConfirmPage(path.elements)), method = FormMethod.get) {
-                                       submitInput(classes = "evil") {
-                                               value = "Delete (Recursive)"
-                                       }
-                               }
-                       }
-       }
-}
-
-context(ApplicationCall)
-private fun UL.render(path: StoragePath, node: TreeNode) {
-       when (node) {
-               is TreeNode.FileNode -> li {
-                       a(href = href(Root.Admin.Vfs.View(path.elements))) {
-                               +path.elements.last()
-                       }
-               }
-               
-               is TreeNode.DirNode -> li {
-                       a(href = href(Root.Admin.Vfs.View(path.elements))) {
-                               +path.elements.last()
-                       }
-                       ul {
-                               render(path, node.children)
-                       }
-               }
-       }
-}
-
-suspend fun ApplicationCall.adminViewVfs(path: StoragePath): HTML.() -> Unit {
-       val tree = fileTree(path)!!
-       
-       return adminPage("VFS - /$path") {
-               main {
-                       h1 { +"/$path" }
-                       
-                       when (tree) {
-                               is TreeNode.FileNode -> table {
-                                       tr {
-                                               th {
-                                                       colSpan = "2"
-                                                       +"/$path"
-                                               }
-                                       }
-                                       tr {
-                                               td {
-                                                       colSpan = "2"
-                                                       iframe {
-                                                               src = href(Root.Admin.Vfs.Inline(path.elements))
-                                                       }
-                                               }
-                                       }
-                                       tr {
-                                               th { +"Last updated" }
-                                               td { dateTime(tree.stats.updated) }
-                                       }
-                                       tr {
-                                               th { +"Size (bytes)" }
-                                               td { +"${tree.stats.size}" }
-                                       }
-                                       tr {
-                                               th { +"Actions" }
-                                               td {
-                                                       ul {
-                                                               li {
-                                                                       a(classes = "button", href = href(Root.Admin.Vfs.Download(path.elements))) {
-                                                                               +"Download"
-                                                                       }
-                                                               }
-                                                               li {
-                                                                       form(action = href(Root.Admin.Vfs.Overwrite(path.elements)), method = FormMethod.post, encType = FormEncType.multipartFormData) {
-                                                                               installCsrfToken()
-                                                                               label {
-                                                                                       fileInput(name = "uploaded")
-                                                                                       +"Upload New Version"
-                                                                               }
-                                                                               submitInput()
-                                                                       }
-                                                               }
-                                                               li {
-                                                                       a(classes = "button", href = href(Root.Admin.Vfs.CopyPage(path.elements))) {
-                                                                               +"Make Copy"
-                                                                       }
-                                                               }
-                                                               li {
-                                                                       a(classes = "button evil", href = href(Root.Admin.Vfs.DeleteConfirmPage(path.elements))) {
-                                                                               +"Delete"
-                                                                       }
-                                                               }
-                                                       }
-                                               }
-                                       }
-                                       tr {
-                                               th { +"Navigate" }
-                                               td {
-                                                       ul {
-                                                               path.elements.indices.forEach { index ->
-                                                                       val parent = path.elements.take(index)
-                                                                       li {
-                                                                               a(href = href(Root.Admin.Vfs.View(parent))) {
-                                                                                       +"/${StoragePath(parent)}"
-                                                                               }
-                                                                       }
-                                                               }
-                                                       }
-                                               }
-                                       }
-                               }
-                               
-                               is TreeNode.DirNode -> ul {
-                                       if (!path.isRoot)
-                                               li {
-                                                       a(href = href(Root.Admin.Vfs.View(path.elements.dropLast(1)))) {
-                                                               +".."
-                                                       }
-                                               }
-                                       
-                                       render(path, tree.children)
-                               }
-                       }
-               }
-       }
-}
-
-private val textExtensions = listOf(
-       "",
-       "groovy",
-       "html",
-       "map",
-       "mtl",
-       "obj",
-       "old",
-       "tpl",
-       "wip",
-)
-
-suspend fun ApplicationCall.adminPreviewFile(path: StoragePath) {
-       attributes.put(PageDoNotCacheAttributeKey, true)
-       
-       val extension = path.elements.last().substringAfterLast('.', "")
-       val type = if (extension in textExtensions) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension)
-       val result = FileStorage.instance.readFile(path) ?: return respond(HttpStatusCode.NotFound)
-       respondBytes(result, type)
-}
-
-private suspend fun fileTreeForCopy(path: StoragePath): TreeNode.DirNode? {
-       return coroutineScope {
-               FileStorage.instance.listDir(path)?.map { entry ->
-                       async {
-                               fileTreeForCopy(path / entry.name)?.let { entry.name to it }
-                       }
-               }?.awaitAll()
-                       ?.filterNotNull()
-                       ?.toMap()
-                       ?.let { TreeNode.DirNode(it) }
-       }
-}
-
-context(ApplicationCall)
-private fun UL.renderForCopy(fromPath: StoragePath, intoPath: StoragePath, node: TreeNode.DirNode) {
-       li {
-               form(method = FormMethod.post, action = href(Root.Admin.Vfs.CopyPost(intoPath.elements))) {
-                       installCsrfToken()
-                       hiddenInput(name = "from") { value = fromPath.toString() }
-                       submitInput { value = "Copy Into /$intoPath" }
-               }
-               ul {
-                       for ((childName, childNode) in node.children)
-                               if (childNode is TreeNode.DirNode)
-                                       renderForCopy(fromPath, intoPath / childName, childNode)
-               }
-       }
-}
-
-suspend fun ApplicationCall.adminShowCopyFile(from: StoragePath): HTML.() -> Unit {
-       if (FileStorage.instance.statFile(from) == null)
-               throw NoSuchElementException("File does not exist")
-               
-       val tree = fileTreeForCopy(StoragePath.Root)!!
-       
-       return adminPage("Copy File /$from") {
-               main {
-                       h1 { +"Choose Destination for /$from" }
-                       ul {
-                               li {
-                                       form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(from.elements))) {
-                                               submitInput { value = "Cancel Copy" }
-                                       }
-                               }
-                               renderForCopy(from, StoragePath.Root, tree)
-                       }
-               }
-       }
-}
-
-suspend fun ApplicationCall.adminDoCopyFile(from: StoragePath, into: StoragePath) {
-       val name = from.elements.last()
-       val dest = into / name
-       
-       if (FileStorage.instance.copyFile(from, dest))
-               redirectHref(Root.Admin.Vfs.View(dest.elements))
-       else
-               respond(HttpStatusCode.Conflict)
-}
-
-suspend fun ApplicationCall.adminUploadFile(path: StoragePath, part: PartData.FileItem) {
-       val name = part.originalFileName ?: throw MissingRequestParameterException("originalFileName")
-       val filePath = path / name
-       
-       val content = withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }
-       if (FileStorage.instance.writeFile(filePath, content))
-               redirectHref(Root.Admin.Vfs.View(filePath.elements))
-       else
-               respond(HttpStatusCode.Conflict)
-}
-
-suspend fun ApplicationCall.adminOverwriteFile(path: StoragePath, part: PartData.FileItem) {
-       if (FileStorage.instance.writeFile(path, withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }))
-               redirectHref(Root.Admin.Vfs.View(path.elements))
-       else
-               respond(HttpStatusCode.Conflict)
-}
-
-suspend fun ApplicationCall.adminConfirmDeleteFile(path: StoragePath) {
-       val stats = FileStorage.instance.statFile(path)
-       if (stats == null)
-               respond(HttpStatusCode.Conflict)
-       else
-               respondHtml(block = adminPage("Confirm Deletion of /$path") {
-                       main {
-                               p {
-                                       +"Are you sure you want to delete the file at /$path? "
-                                       strong { +"It will be gone forever!" }
-                               }
-                               table {
-                                       tr {
-                                               th { +"Last Updated" }
-                                               td { dateTime(stats.updated) }
-                                       }
-                                       tr {
-                                               th { +"Size (bytes)" }
-                                               td { +"${stats.size}" }
-                                       }
-                               }
-                               
-                               br
-                               
-                               div {
-                                       style = "text-align:center"
-                                       form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(path.elements))) {
-                                               submitInput { value = "No, take me back" }
-                                       }
-                                       +Entities.nbsp
-                                       form(method = FormMethod.post, action = href(Root.Admin.Vfs.DeleteConfirmPost(path.elements))) {
-                                               installCsrfToken()
-                                               submitInput(classes = "evil") { value = "Yes, delete it" }
-                                       }
-                               }
-                       }
-               })
-}
-
-suspend fun ApplicationCall.adminDeleteFile(path: StoragePath) {
-       if (FileStorage.instance.eraseFile(path))
-               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)))
-       else
-               respond(HttpStatusCode.Conflict)
-}
-
-suspend fun ApplicationCall.adminMakeDirectory(path: StoragePath, name: String) {
-       val dirPath = path / name
-       
-       if (FileStorage.instance.createDir(dirPath))
-               redirectHref(Root.Admin.Vfs.View(dirPath.elements))
-       else
-               respond(HttpStatusCode.Conflict)
-}
-
-suspend fun ApplicationCall.adminConfirmRemoveDirectory(path: StoragePath) {
-       val entries = FileStorage.instance.listDir(path)?.sortedBy { it.name }?.sortedBy { it.type }
-       if (entries == null)
-               respond(HttpStatusCode.Conflict)
-       else
-               respondHtml(block = adminPage("Confirm Deletion of /$path") {
-                       main {
-                               p {
-                                       +"Are you sure you want to delete the directory at /$path? "
-                                       strong { +"It, and all of its contents, will be gone forever!" }
-                               }
-                               ul {
-                                       for (entry in entries)
-                                               li {
-                                                       +entry.name
-                                                       if (entry.type == StoredFileType.DIRECTORY)
-                                                               +"/"
-                                               }
-                               }
-                               
-                               br
-                               
-                               div {
-                                       style = "text-align:center"
-                                       form(method = FormMethod.get, action = href(Root.Admin.Vfs.View(path.elements))) {
-                                               submitInput { value = "No, take me back" }
-                                       }
-                                       +Entities.nbsp
-                                       form(method = FormMethod.post, action = href(Root.Admin.Vfs.RmDirConfirmPost(path.elements))) {
-                                               installCsrfToken()
-                                               submitInput(classes = "evil") { value = "Yes, delete it" }
-                                       }
-                               }
-                       }
-               })
-}
-
-suspend fun ApplicationCall.adminRemoveDirectory(path: StoragePath) {
-       if (FileStorage.instance.deleteDir(path))
-               redirectHref(Root.Admin.Vfs.View(path.elements.dropLast(1)))
-       else
-               respond(HttpStatusCode.Conflict)
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_user.kt
deleted file mode 100644 (file)
index 4b72023..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-package info.mechyrdia.data
-
-import com.mongodb.client.model.Updates
-import info.mechyrdia.OwnerNationId
-import info.mechyrdia.auth.UserSession
-import info.mechyrdia.lore.NationProfileSidebar
-import info.mechyrdia.lore.page
-import info.mechyrdia.lore.redirectHref
-import info.mechyrdia.lore.standardNavBar
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.href
-import info.mechyrdia.route.installCsrfToken
-import io.ktor.server.application.*
-import io.ktor.server.sessions.*
-import kotlinx.coroutines.flow.toList
-import kotlinx.html.*
-
-fun ApplicationCall.currentUserPage(): Nothing {
-       val currNationId = sessions.get<UserSession>()?.nationId
-       if (currNationId == null)
-               redirectHref(Root.Auth.LoginPage())
-       else
-               redirectHref(Root.User.ById(currNationId))
-}
-
-suspend fun ApplicationCall.userPage(userId: Id<NationData>): HTML.() -> Unit {
-       val currNation = currentNation()
-       val viewingNation = nationCache.getNation(userId)
-       
-       val comments = CommentRenderData(
-               Comment.getCommentsBy(viewingNation.id).toList(),
-               nationCache
-       )
-       
-       return page(viewingNation.name, standardNavBar(), NationProfileSidebar(viewingNation)) {
-               section {
-                       a { id = "page-top" }
-                       h1 { +viewingNation.name }
-                       if (currNation?.id == OwnerNationId) {
-                               if (viewingNation.isBanned) {
-                                       p { +"This user is banned" }
-                                       val unbanLink = href(Root.Admin.Unban(viewingNation.id))
-                                       a(href = unbanLink) {
-                                               installCsrfToken(unbanLink)
-                                               +"Unban"
-                                       }
-                               } else {
-                                       val banLink = href(Root.Admin.Ban(viewingNation.id))
-                                       a(href = banLink) {
-                                               installCsrfToken(banLink)
-                                               +"Ban"
-                                       }
-                               }
-                       }
-                       for (comment in comments)
-                               commentBox(comment, currNation?.id, viewingUserPage = true)
-               }
-       }
-}
-
-suspend fun ApplicationCall.adminBanUserRoute(userId: Id<NationData>): Nothing {
-       val bannedNation = nationCache.getNation(userId)
-       
-       if (!bannedNation.isBanned)
-               NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, true))
-       
-       redirectHref(Root.User.ById(userId))
-}
-
-suspend fun ApplicationCall.adminUnbanUserRoute(userId: Id<NationData>): Nothing {
-       val bannedNation = nationCache.getNation(userId)
-       
-       if (bannedNation.isBanned)
-               NationData.Table.set(bannedNation.id, Updates.set(NationData::isBanned.serialName, false))
-       
-       redirectHref(Root.User.ById(userId))
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/April1st.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/April1st.kt
new file mode 100644 (file)
index 0000000..d2db934
--- /dev/null
@@ -0,0 +1,33 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import io.ktor.server.application.*
+import java.time.Instant
+import java.time.Month
+import java.time.ZoneId
+
+private val myTimeZone: ZoneId = ZoneId.of("America/New_York")
+
+fun isApril1st(time: Instant = Instant.now()): Boolean {
+       val zonedDateTime = time.atZone(myTimeZone)
+       return zonedDateTime.month == Month.APRIL && zonedDateTime.dayOfMonth == 1
+}
+
+context(ApplicationCall)
+suspend fun redirectFileOnApril1st(requestedFile: StoragePath): StoragePath? {
+       if (!april1stMode.isEnabled) return null
+       
+       val path = StoragePath.april1Dir / requestedFile.elements
+       if (FileStorage.instance.statFile(path) == null) return null
+       return path
+}
+
+context(ApplicationCall)
+suspend fun getAssetFile(requestedFile: StoragePath): StoragePath {
+       return redirectFileOnApril1st(requestedFile) ?: requestedFile
+}
+
+suspend fun ApplicationCall.respondAsset(assetFile: StoragePath) {
+       respondCompressedFile(getAssetFile(assetFile))
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleListing.kt
new file mode 100644 (file)
index 0000000..e2ea11b
--- /dev/null
@@ -0,0 +1,72 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.Configuration
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import io.ktor.server.application.*
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.html.UL
+import kotlinx.html.a
+import kotlinx.html.li
+import kotlinx.html.ul
+
+data class ArticleNode(val name: String, val title: String, val subNodes: List<ArticleNode>?)
+
+suspend fun rootArticleNodeList(): List<ArticleNode> = StoragePath.articleDir.toArticleNode().subNodes.orEmpty()
+
+suspend fun StoragePath.toArticleNode(): ArticleNode = ArticleNode(
+       name,
+       toFriendlyPageTitle(),
+       coroutineScope {
+               val path = this@toArticleNode
+               FileStorage.instance.listDir(path)?.map { (name, _) ->
+                       val subPath = path / name
+                       async { subPath.toArticleNode() }
+               }?.awaitAll().orEmpty()
+       }.sortedBy { it.name }.sortedBy { it.subNodes == null }
+)
+
+private val String.isViewable: Boolean
+       get() = Configuration.Current.isDevMode || !(endsWith(".wip") || endsWith(".old"))
+
+val ArticleNode.isViewable: Boolean
+       get() = name.isViewable
+
+val StoragePath.isViewable: Boolean
+       get() = name.isViewable
+
+context(ApplicationCall)
+fun List<ArticleNode>.renderInto(list: UL, base: List<String> = emptyList(), format: LoreArticleFormat = LoreArticleFormat.HTML) {
+       for (node in this) {
+               if (node.isViewable)
+                       list.li {
+                               val nodePath = base + node.name
+                               a(href = href(Root.LorePage(nodePath, format))) { +node.title }
+                               node.subNodes?.let { subNodes ->
+                                       ul {
+                                               subNodes.renderInto(this, nodePath, format)
+                                       }
+                               }
+                       }
+       }
+}
+
+suspend fun StoragePath.toFriendlyPageTitle() = ArticleTitleCache.get(this)
+       ?: if (elements.size > 1)
+               elements.lastOrNull()?.split('-')?.joinToString(separator = " ") { word ->
+                       word.lowercase().replaceFirstChar { it.titlecase() }
+               }.orEmpty()
+       else TOC_TITLE
+
+suspend fun StoragePath.toFriendlyPathTitle(): String {
+       val lorePath = elements.drop(1)
+       if (lorePath.isEmpty()) return TOC_TITLE
+       
+       return lorePath.indices.drop(1).map { index ->
+               StoragePath(lorePath.take(index)).toFriendlyPageTitle()
+       }.joinToString(separator = " - ")
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ArticleTitles.kt
new file mode 100644 (file)
index 0000000..337126f
--- /dev/null
@@ -0,0 +1,20 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+
+object ArticleTitleCache : FileDependentCache<String>() {
+       override suspend fun processFile(path: StoragePath): String? {
+               if (path !in StoragePath.articleDir)
+                       return null
+               
+               val bytes = FileStorage.instance.readFile(path) ?: return null
+               val text = String(bytes)
+               
+               return text
+                       .lineSequence()
+                       .first()
+                       .removePrefix("[h1]")
+                       .removeSuffix("[/h1]")
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt
new file mode 100644 (file)
index 0000000..4db2e64
--- /dev/null
@@ -0,0 +1,53 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import io.ktor.util.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.time.Instant
+import java.util.concurrent.ConcurrentHashMap
+
+val StoragePathAttributeKey = AttributeKey<StoragePath>("Mechyrdia.StoragePath")
+
+abstract class FileDependentCache<T : Any> {
+       private inner class Entry(updated: Instant?, data: T?) {
+               private var updated: Instant = updated ?: Instant.MIN
+               var data: T? = data
+                       private set
+               
+               private val updateLock = Mutex()
+               
+               private fun clear() {
+                       updated = Instant.MIN
+                       data = null
+               }
+               
+               suspend fun updateIfNeeded(path: StoragePath): Entry {
+                       return updateLock.withLock {
+                               FileStorage.instance.statFile(path)?.updated?.let { fileUpdated ->
+                                       if (updated < fileUpdated) {
+                                               updated = fileUpdated
+                                               data = processFile(path)
+                                       }
+                                       this
+                               } ?: apply { clear() }
+                       }
+               }
+       }
+       
+       private val cacheLock = Mutex()
+       private val cache = ConcurrentHashMap<StoragePath, Entry>()
+       
+       private suspend fun Entry(path: StoragePath) = cacheLock.withLock {
+               cache.computeIfAbsent(path) {
+                       Entry(null, null)
+               }
+       }
+       
+       protected abstract suspend fun processFile(path: StoragePath): T?
+       
+       suspend fun get(path: StoragePath): T? {
+               return Entry(path).updateIfNeeded(path).data
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetCompression.kt
new file mode 100644 (file)
index 0000000..030ce60
--- /dev/null
@@ -0,0 +1,55 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import info.mechyrdia.data.respondStoredFile
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+import java.io.FilterOutputStream
+import java.io.OutputStream
+import java.util.zip.DeflaterOutputStream
+import java.util.zip.GZIPOutputStream
+
+private val gzippedCache = CompressedCache("gzip", ::GZIPOutputStream)
+private val deflatedCache = CompressedCache("deflate", ::DeflaterOutputStream)
+
+private fun getCacheByEncoding(encoding: String) = when (encoding) {
+       "gzip" -> gzippedCache
+       "deflate" -> deflatedCache
+       else -> null
+}
+
+private fun ApplicationCall.compressedCache(): CompressedCache? {
+       return request.acceptEncodingItems()
+               .mapNotNull { item -> getCacheByEncoding(item.value)?.let { it to item.quality } }
+               .maxByOrNull { it.second }
+               ?.first
+}
+
+suspend fun ApplicationCall.respondCompressedFile(path: StoragePath) {
+       val cache = compressedCache() ?: return respondStoredFile(path)
+       val compressedBytes = cache.get(path) ?: return respond(HttpStatusCode.NotFound)
+       attributes.put(StoragePathAttributeKey, path)
+       response.header(HttpHeaders.ContentEncoding, cache.encoding)
+       respondBytes(compressedBytes)
+}
+
+private class CompressedCache(val encoding: String, private val compressorFactory: (OutputStream, Boolean) -> FilterOutputStream) : FileDependentCache<ByteArray>() {
+       override suspend fun processFile(path: StoragePath): ByteArray? {
+               val fileContents = FileStorage.instance.readFile(path) ?: return null
+               
+               return withContext(Dispatchers.IO) {
+                       ByteArrayOutputStream().also { oStream ->
+                               compressorFactory(oStream, true).use { gzip ->
+                                       gzip.write(fileContents)
+                                       gzip.flush()
+                               }
+                       }.toByteArray()
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/AssetHashing.kt
new file mode 100644 (file)
index 0000000..eab3a87
--- /dev/null
@@ -0,0 +1,91 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import io.ktor.http.content.*
+import io.ktor.server.application.*
+import io.ktor.server.http.content.*
+import kotlinx.coroutines.*
+import java.io.IOException
+import java.io.OutputStream
+import java.security.MessageDigest
+import java.util.*
+
+private class DigestingOutputStream(stomach: MessageDigest) : OutputStream() {
+       private var stomachStore: MessageDigest? = stomach
+       
+       private val stomach: MessageDigest
+               get() = stomachStore ?: throw IOException("Attempt to use DigestingOutputStream after it has been closed")
+       
+       val isWritable: Boolean
+               get() = stomachStore != null
+       
+       private var resultStore: ByteArray? = null
+       
+       val result: ByteArray
+               get() = resultStore ?: throw IOException("Attempt to retrieve result of DigestingOutputStream before it has finished")
+       
+       val isDone: Boolean
+               get() = resultStore != null
+       
+       override fun write(b: Int) {
+               stomach.update(b.toByte())
+       }
+       
+       override fun write(b: ByteArray) {
+               stomach.update(b)
+       }
+       
+       override fun write(b: ByteArray, off: Int, len: Int) {
+               stomach.update(b, off, len)
+       }
+       
+       override fun close() {
+               resultStore = stomach.digest()
+               stomachStore = null
+       }
+       
+       inline fun useAndGet(block: (DigestingOutputStream) -> Unit): ByteArray {
+               use(block)
+               return result
+       }
+}
+
+private class FileHashCache(val hashAlgo: String) : FileDependentCache<ByteArray>() {
+       private val hashinator: ThreadLocal<MessageDigest> = ThreadLocal.withInitial { MessageDigest.getInstance(hashAlgo) }
+       
+       override suspend fun processFile(path: StoragePath): ByteArray? {
+               val fileContents = FileStorage.instance.readFile(path) ?: return null
+               
+               return withContext(Dispatchers.IO) {
+                       DigestingOutputStream(hashinator.get()).useAndGet { oStream ->
+                               oStream.write(fileContents)
+                       }
+               }
+       }
+}
+
+private val eTagCache = FileHashCache("SHA-384")
+
+private val b64Encoder: Base64.Encoder = Base64.getUrlEncoder()
+
+suspend fun StoragePath.eTag(): String? = eTagCache.get(this)?.let(b64Encoder::encodeToString)
+
+private suspend fun StoragePath.getVersionHeaders() = coroutineScope {
+       listOf(
+               async {
+                       eTag()?.let {
+                               EntityTagVersion(it)
+                       }
+               },
+               async {
+                       FileStorage.instance.statFile(this@getVersionHeaders)?.updated?.toEpochMilli()?.let {
+                               LastModifiedVersion(it)
+                       }
+               }
+       ).awaitAll().filterNotNull()
+}
+
+suspend fun getVersionHeaders(call: ApplicationCall): List<Version> {
+       return call.attributes.getOrNull(StoragePathAttributeKey)?.getVersionHeaders().orEmpty()
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/FileData.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/FileData.kt
new file mode 100644 (file)
index 0000000..42a887d
--- /dev/null
@@ -0,0 +1,32 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.time.Instant
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+fun <T : Any> storedData(path: StoragePath, loader: suspend (StoragePath) -> T?): ReadOnlyProperty<Any?, suspend () -> T?> = object : ReadOnlyProperty<Any?, suspend () -> T?> {
+       private var loadedValue: T? = null
+       private var lastChanged = Instant.MIN
+       
+       private val lock = Mutex()
+       
+       override fun getValue(thisRef: Any?, property: KProperty<*>): suspend () -> T? {
+               return suspend {
+                       lock.withLock {
+                               val cached = loadedValue
+                               val lastMod = FileStorage.instance.statFile(path)?.updated ?: return@withLock null
+                               
+                               if (lastChanged < lastMod) {
+                                       lastChanged = lastMod
+                                       loader(path).also {
+                                               loadedValue = it
+                                       }
+                               } else cached
+                       }
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt
new file mode 100644 (file)
index 0000000..566dba7
--- /dev/null
@@ -0,0 +1,10 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.route.href
+import io.ktor.server.application.*
+
+data class HttpRedirectException(val url: String, val permanent: Boolean) : RuntimeException()
+
+fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent)
+
+inline fun <reified T : Any> ApplicationCall.redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserBuilder.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserBuilder.kt
new file mode 100644 (file)
index 0000000..f5b90d4
--- /dev/null
@@ -0,0 +1,169 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.Comment
+import info.mechyrdia.data.Id
+
+abstract class BuilderLexerProcessor<TContext> : LexerTagFallback<TContext, Unit>, LexerTextProcessor<TContext, Unit>, LexerLineBreakProcessor<TContext, Unit>, LexerCombiner<TContext, Unit> {
+       override fun processInvalidTag(env: LexerTagEnvironment<TContext, Unit>, tag: String, param: String?, subNodes: ParserTree) {
+               // no-op
+       }
+       
+       override fun processText(env: LexerTagEnvironment<TContext, Unit>, text: String) {
+               // no-op
+       }
+       
+       override fun processLineBreak(env: LexerTagEnvironment<TContext, Unit>) {
+               // no-op
+       }
+       
+       override fun combine(env: LexerTagEnvironment<TContext, Unit>, subjects: List<Unit>) {
+               // no-op
+       }
+}
+
+typealias BuilderTag<TContext> = LexerTagProcessor<TContext, Unit>
+
+object ToCBuilderProcessor : BuilderLexerProcessor<TableOfContentsBuilder>()
+
+class TableOfContentsBuilder {
+       private var title: String? = null
+       private val levels = mutableListOf<Int>()
+       private val links = mutableListOf<NavLink>()
+       
+       fun addHeader(text: String, level: Int, toAnchor: String) {
+               if (level == 0) {
+                       if (title == null)
+                               title = text
+                       
+                       return
+               }
+               
+               if (level > levels.size)
+                       levels.add(1)
+               else {
+                       val newLevels = levels.take(level).mapIndexed { i, n -> if (i == level - 1) n + 1 else n }
+                       levels.clear()
+                       levels.addAll(newLevels)
+               }
+               
+               val number = levels.joinToString(separator = ".") { it.toString() }
+               links.add(NavLink("#$toAnchor", "$number. $text", aClasses = "left"))
+       }
+       
+       private var description: String? = null
+       private var image: String? = null
+       
+       fun addDescription(plainText: String) {
+               description = description.orEmpty() + plainText
+       }
+       
+       fun addImage(path: String, overWrite: Boolean = false) {
+               if (overWrite || image == null)
+                       image = path
+       }
+       
+       fun toPageTitle() = title ?: MISSING_TITLE
+       
+       fun toOpenGraph() = description?.let { desc ->
+               image?.let { image ->
+                       OpenGraphData(desc, image)
+               }
+       }
+       
+       fun toNavBar(): List<NavItem> = listOf(NavLink("#page-top", title ?: MISSING_TITLE, aClasses = "left")) + links.toList()
+       
+       companion object {
+               const val MISSING_TITLE = "Untitled"
+       }
+}
+
+private class ToCHeaderBuilderTag(val level: Int) : BuilderTag<TableOfContentsBuilder> {
+       override fun processTag(env: LexerTagEnvironment<TableOfContentsBuilder, Unit>, param: String?, subNodes: ParserTree) {
+               val label = subNodes.treeToText()
+               val anchor = label.sanitizeAnchor()
+               
+               env.context.addHeader(label, level, anchor)
+       }
+}
+
+private class ToCPropertyBuilderTag(val converter: (String) -> String, val setter: TableOfContentsBuilder.(String) -> Unit) : BuilderTag<TableOfContentsBuilder> {
+       override fun processTag(env: LexerTagEnvironment<TableOfContentsBuilder, Unit>, param: String?, subNodes: ParserTree) {
+               env.context.setter(converter(subNodes.treeToText()))
+       }
+}
+
+fun String.imagePathToOpenGraphValue() = "https://mechyrdia.info/assets/images/${sanitizeLink()}"
+
+enum class ToCBuilderTag(val type: BuilderTag<TableOfContentsBuilder>) {
+       H1(ToCHeaderBuilderTag(0)),
+       H2(ToCHeaderBuilderTag(1)),
+       H3(ToCHeaderBuilderTag(2)),
+       H4(ToCHeaderBuilderTag(3)),
+       H5(ToCHeaderBuilderTag(4)),
+       H6(ToCHeaderBuilderTag(5)),
+       DESC(ToCPropertyBuilderTag({ it }, TableOfContentsBuilder::addDescription)),
+       IMAGE(ToCPropertyBuilderTag(String::imagePathToOpenGraphValue) { addImage(it, false) }),
+       THUMB(ToCPropertyBuilderTag(String::imagePathToOpenGraphValue) { addImage(it, true) }),
+       ;
+       
+       companion object {
+               val asTags = LexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+fun ParserTree.buildToC(builder: TableOfContentsBuilder) {
+       LexerTagEnvironment(
+               builder,
+               ToCBuilderTag.asTags,
+               ToCBuilderProcessor,
+               ToCBuilderProcessor,
+               ToCBuilderProcessor,
+               ToCBuilderProcessor,
+       ).processTree(this)
+}
+
+object RepliesBuilderProcessor : BuilderLexerProcessor<CommentRepliesBuilder>()
+
+class CommentRepliesBuilder {
+       private val repliesTo = mutableSetOf<Id<Comment>>()
+       
+       fun addReplyTag(reply: Id<Comment>) {
+               repliesTo += reply
+       }
+       
+       fun toReplySet() = repliesTo.toSet()
+}
+
+val ID_REGEX = Regex("[A-IL-TVX-Z0-9]{24}")
+fun sanitizeId(html: String) = ID_REGEX.matchEntire(html)?.value
+
+enum class RepliesBuilderTag(val type: BuilderTag<CommentRepliesBuilder>) {
+       REPLY(
+               BuilderTag { env, _, content ->
+                       sanitizeId(content.treeToText())?.let { id ->
+                               env.context.addReplyTag(Id(id))
+                       }
+               }
+       );
+       
+       companion object {
+               val asTags = LexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+fun ParserTree.buildReplies(builder: CommentRepliesBuilder) {
+       LexerTagEnvironment(
+               builder,
+               RepliesBuilderTag.asTags,
+               RepliesBuilderProcessor,
+               RepliesBuilderProcessor,
+               RepliesBuilderProcessor,
+               RepliesBuilderProcessor,
+       ).processTree(this)
+}
+
+fun getReplies(commentContents: String): Set<Id<Comment>> {
+       val builder = CommentRepliesBuilder()
+       commentContents.parseAs(builder, ParserTree::buildReplies)
+       return builder.toReplySet()
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt
new file mode 100644 (file)
index 0000000..614a0ee
--- /dev/null
@@ -0,0 +1,734 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.JsonStorageCodec
+import io.ktor.util.*
+import kotlinx.html.*
+import kotlinx.html.org.w3c.dom.events.Event
+import kotlinx.html.stream.appendHTML
+import kotlinx.serialization.json.JsonPrimitive
+import kotlin.text.toCharArray
+
+typealias HtmlBuilderContext = Unit
+typealias HtmlBuilderSubject = TagConsumer<*>.() -> Any?
+
+context(T)
+operator fun <T : Tag> (TagConsumer<*>.() -> Any?).unaryPlus() = with(HtmlLexerTagConsumer(consumer)) { this@unaryPlus() }
+
+fun (TagConsumer<*>.() -> Any?).toFragment() = StringBuilder()
+       .appendHTML()
+       .also { builder ->
+               with(HtmlLexerTagConsumer(builder)) { this@toFragment() }
+       }
+       .finalize()
+       .toString()
+
+class HtmlLexerTagConsumer private constructor(private val downstream: TagConsumer<*>) : TagConsumer<Unit> {
+       override fun onTagStart(tag: Tag) {
+               downstream.onTagStart(tag)
+       }
+       
+       override fun onTagAttributeChange(tag: Tag, attribute: String, value: String?) {
+               downstream.onTagAttributeChange(tag, attribute, value)
+       }
+       
+       override fun onTagContent(content: CharSequence) {
+               downstream.onTagContent(content)
+       }
+       
+       override fun onTagContentEntity(entity: Entities) {
+               downstream.onTagContentEntity(entity)
+       }
+       
+       override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
+               downstream.onTagContentUnsafe(block)
+       }
+       
+       override fun onTagEvent(tag: Tag, event: String, value: (Event) -> Unit) {
+               downstream.onTagEvent(tag, event, value)
+       }
+       
+       override fun onTagEnd(tag: Tag) {
+               downstream.onTagEnd(tag)
+       }
+       
+       override fun onTagComment(content: CharSequence) {
+               downstream.onTagComment(content)
+       }
+       
+       override fun finalize() {
+               // no-op
+       }
+       
+       companion object {
+               operator fun invoke(downstream: TagConsumer<*>) =
+                       if (downstream is HtmlLexerTagConsumer)
+                               downstream
+                       else
+                               HtmlLexerTagConsumer(downstream)
+       }
+}
+
+context(C)
+operator fun <T, C : TagConsumer<T>> String.unaryPlus() = onTagContent(this)
+
+context(C)
+operator fun <T, C : TagConsumer<T>> Entities.unaryPlus() = onTagContentEntity(this)
+
+fun <T, C : TagConsumer<T>> C.unsafe(block: Unsafe.() -> Unit) = onTagContentUnsafe(block)
+
+fun ParserTree.shouldSplitSections(): Boolean = firstOrNull()?.let {
+       it is ParserTreeNode.Tag && it isTag "h1"
+} == true
+
+fun ParserTree.splitSections(): List<ParserTree> = splitBefore {
+       it is ParserTreeNode.Tag && it isTag "h2"
+}
+
+fun ParserTreeNode.isWhitespace() = when (this) {
+       is ParserTreeNode.Text -> text.isBlank()
+       ParserTreeNode.LineBreak -> true
+       is ParserTreeNode.Tag -> false
+}
+
+fun ParserTree.isWhitespace() = all { it.isWhitespace() }
+
+fun ParserTreeNode.isParagraph(inlineTags: Set<String>): Boolean = when (this) {
+       is ParserTreeNode.Text -> true
+       ParserTreeNode.LineBreak -> false
+       is ParserTreeNode.Tag -> this isTag inlineTags && subNodes.isParagraph(inlineTags)
+}
+
+fun ParserTree.isParagraph(inlineTags: Set<String>): Boolean = any { it.isParagraph(inlineTags) }
+
+fun ParserTree.splitParagraphs(): List<ParserTree> = splitOn { it == ParserTreeNode.LineBreak }
+
+fun ParserTree.toHtmlParagraph(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>) = if (isEmpty())
+       null
+else if (isParagraph(HtmlLexerProcessor.inlineTags)) {
+       val concat = HtmlLexerProcessor.combineInline(env, this)
+       ({ p { +concat } })
+} else
+       HtmlLexerProcessor.combineInline(env, this)
+
+fun ParserTree.splitHtmlParagraphs(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>): List<HtmlBuilderSubject> =
+       splitParagraphs().mapNotNull { paragraph ->
+               paragraph.toHtmlParagraph(env)
+       }
+
+object HtmlLexerProcessor : LexerTagFallback<HtmlBuilderContext, HtmlBuilderSubject>, LexerTextProcessor<HtmlBuilderContext, HtmlBuilderSubject>, LexerLineBreakProcessor<HtmlBuilderContext, HtmlBuilderSubject>, LexerCombiner<HtmlBuilderContext, HtmlBuilderSubject> {
+       val inlineTags = setOf(
+               "b",
+               "i",
+               "u",
+               "s",
+               "sup",
+               "sub",
+               "color",
+               "ipa",
+               "code",
+               "desc",
+               "link",
+               "extlink",
+               "lang",
+               "url",
+               "reply",
+       )
+       
+       override fun processInvalidTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, tag: String, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               return {
+                       +if (param == null) "[$tag]" else "[$tag=$param]"
+                       env.processTree(subNodes)()
+                       +"[/$tag]"
+               }
+       }
+       
+       override fun processText(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, text: String): HtmlBuilderSubject {
+               return { +text }
+       }
+       
+       override fun processLineBreak(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>): HtmlBuilderSubject {
+               return {
+                       br()
+                       br()
+               }
+       }
+       
+       override fun processAndCombine(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
+               return combinePage(env, nodes)
+       }
+       
+       fun combinePage(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
+               return if (nodes.shouldSplitSections()) {
+                       val pageParts = nodes.splitSections().map { combineBlock(env, it) }
+                       ({
+                               for (pagePart in pageParts) section { +pagePart }
+                       })
+               } else
+                       combineBlock(env, nodes)
+       }
+       
+       fun combineItem(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
+               return if (nodes.any { it == ParserTreeNode.LineBreak }) {
+                       val paragraphs = nodes.splitHtmlParagraphs(env)
+                       ({
+                               for (paragraph in paragraphs) paragraph()
+                       })
+               } else
+                       combineInline(env, nodes)
+       }
+       
+       fun combineBlock(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
+               return if (nodes.any { it == ParserTreeNode.LineBreak }) {
+                       val paragraphs = nodes.splitHtmlParagraphs(env)
+                       ({
+                               for (paragraph in paragraphs) paragraph()
+                       })
+               } else if (nodes.isParagraph(inlineTags)) {
+                       val concat = combineInline(env, nodes)
+                       ({ p { +concat } })
+               } else
+                       combineInline(env, nodes)
+       }
+       
+       fun combineInline(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
+               return combine(env, nodes.map(env::processNode))
+       }
+       
+       fun combineLayout(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
+               return combine(env, nodes.filterNot(ParserTreeNode::isWhitespace).map(env::processNode))
+       }
+       
+       override fun combine(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, subjects: List<HtmlBuilderSubject>): HtmlBuilderSubject {
+               return { for (subject in subjects) subject() }
+       }
+}
+
+fun interface HtmlLexerTag : LexerTagProcessor<HtmlBuilderContext, HtmlBuilderSubject>
+
+class HtmlMetadataLexerTag(val absorb: Boolean) : HtmlLexerTag {
+       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               return if (absorb) ({}) else HtmlLexerProcessor.combineInline(env, subNodes)
+       }
+}
+
+fun ParserTree.treeToText(): String = joinToString(separator = "") {
+       when (it) {
+               is ParserTreeNode.Text -> it.text
+               ParserTreeNode.LineBreak -> " "
+               is ParserTreeNode.Tag -> it.subNodes.treeToText()
+       }
+}.trim()
+
+fun interface HtmlTextBodyLexerTag : HtmlLexerTag {
+       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               return processTag(env, param, subNodes.treeToText())
+       }
+       
+       fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, innerText: String): HtmlBuilderSubject
+}
+
+typealias TagCreator = TagConsumer<*>.(block: Tag.() -> Unit) -> Unit
+
+fun <T1> (TagConsumer<*>.(T1?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator {
+       return {
+               this@toTagCreator(null, it)
+       }
+}
+
+fun <T1, T2> (TagConsumer<*>.(T1?, T2?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator {
+       return {
+               this@toTagCreator(null, null, it)
+       }
+}
+
+fun <T1, T2, T3> (TagConsumer<*>.(T1?, T2?, T3?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator {
+       return {
+               this@toTagCreator(null, null, null, it)
+       }
+}
+
+enum class HtmlTagMode {
+       INLINE,
+       BLOCK,
+       ITEM,
+       LAYOUT;
+       
+       fun combine(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, subNodes: ParserTree) = when (this) {
+               INLINE -> HtmlLexerProcessor.combineInline(env, subNodes)
+               BLOCK -> HtmlLexerProcessor.combineBlock(env, subNodes)
+               ITEM -> HtmlLexerProcessor.combineItem(env, subNodes)
+               LAYOUT -> HtmlLexerProcessor.combineLayout(env, subNodes)
+       }
+}
+
+class HtmlTagLexerTag(
+       val attributes: (String?) -> Map<String, String> = { _ -> emptyMap() },
+       val tagMode: HtmlTagMode = HtmlTagMode.BLOCK,
+       val tagCreator: TagCreator
+) : HtmlLexerTag {
+       constructor(attributes: Map<String, String>, tagMode: HtmlTagMode = HtmlTagMode.BLOCK, tagCreator: TagCreator) : this({ attributes }, tagMode, tagCreator)
+       
+       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               val body = tagMode.combine(env, subNodes)
+               val calculatedAttributes = attributes(param)
+               
+               return {
+                       tagCreator {
+                               for ((name, value) in calculatedAttributes)
+                                       attributes[name] = value
+                               +body
+                       }
+               }
+       }
+}
+
+val NON_ANCHOR_CHAR = Regex("[^a-zA-Z\\d\\-]+")
+fun String.sanitizeAnchor() = replace(NON_ANCHOR_CHAR, "-")
+
+fun ParserTree.treeToAnchorText(): String = treeToText().sanitizeAnchor()
+
+class HtmlHeaderLexerTag(val tagCreator: TagCreator, val anchor: (ParserTree) -> String?) : HtmlLexerTag {
+       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               return {
+                       val anchorId = anchor(subNodes)
+                       
+                       anchorId?.let { a { id = it } }
+                       tagCreator {
+                               +subNodes.treeToText()
+                       }
+                       
+                       val anchorHash = anchorId?.let { "#$it" }.orEmpty()
+                       script { unsafe { +"window.checkRedirectTarget(\"$anchorHash\");" } }
+               }
+       }
+}
+
+fun repeatColorDigits(color: String) = when (color.length) {
+       6 -> color
+       3 -> {
+               val (r, g, b) = color.toCharArray()
+               "$r$r$g$g$b$b"
+       }
+       
+       else -> null
+}
+
+fun processColor(param: String?): Map<String, String> = param
+       ?.removePrefix("#")
+       ?.let(::repeatColorDigits)
+       ?.toIntOrNull(16)
+       ?.toString(16)
+       ?.padStart(6, '0')
+       ?.let { mapOf("style" to "color:#$it") }
+       .orEmpty()
+
+private val VALID_ALIGNMENTS = mapOf(
+       "left" to "text-align:left",
+       "right" to "text-align:right",
+       "center" to "text-align:center",
+       "justify" to "text-align:justify;text-align-last:left"
+)
+
+fun processAlign(param: String?): Map<String, String> = param
+       ?.lowercase()
+       ?.let { VALID_ALIGNMENTS[it] }
+       ?.let { mapOf("style" to it) }
+       .orEmpty()
+
+private val VALID_FLOATS = mapOf(
+       "left" to "float:left;max-width:var(--aside-width)",
+       "right" to "float:right;max-width:var(--aside-width)",
+)
+
+fun processFloat(param: String?): Map<String, String> = param
+       ?.lowercase()
+       ?.let { VALID_FLOATS[it] }
+       ?.let { mapOf("style" to it) }
+       .orEmpty()
+
+val NON_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._/]")
+val DOT_CHARS = Regex("\\.+")
+fun String.sanitizeLink() = replace(NON_LINK_CHAR, "").replace(DOT_CHARS, ".")
+
+val NON_EXT_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._:/]")
+fun String.sanitizeExtLink() = replace(NON_EXT_LINK_CHAR, "").replace(DOT_CHARS, ".")
+
+val NON_EXT_IMG_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._/]")
+fun String.sanitizeExtImgLink() = replace(NON_EXT_IMG_LINK_CHAR, "").replace(DOT_CHARS, ".")
+
+fun getSizeParam(tagParam: String?): Pair<Int?, Int?> = tagParam?.let { resolution ->
+       val parts = resolution.split('x')
+       parts.getOrNull(0)?.toIntOrNull() to parts.getOrNull(1)?.toIntOrNull()
+} ?: (null to null)
+
+fun getImageSizeStyleValue(width: Int?, height: Int?) = width?.let { "width: calc(var(--media-size-unit) * $it);" }.orEmpty() + height?.let { "height: calc(var(--media-size-unit) * $it);" }.orEmpty()
+fun getImageSizeAttributes(width: Int?, height: Int?) = " style=\"${getImageSizeStyleValue(width, height)}\""
+
+fun processTableCell(param: String?): Map<String, String> {
+       val (width, height) = getSizeParam(param)
+       return width?.let { mapOf("colspan" to "$it") }.orEmpty() + height?.let { mapOf("rowspan" to "$it") }.orEmpty()
+}
+
+fun String.toInternalUrl() = if (startsWith("/")) "/lore$this" else "./$this"
+fun String.toExternalUrl() = if (startsWith("http:")) "https:${substring(5)}" else this
+
+fun processInternalLink(param: String?): Map<String, String> = param
+       ?.sanitizeLink()
+       ?.toInternalUrl()
+       ?.let { mapOf("href" to it) }
+       .orEmpty()
+
+fun processExternalLink(param: String?): Map<String, String> = param
+       ?.sanitizeExtLink()
+       ?.toExternalUrl()
+       ?.let { mapOf("href" to it) }
+       .orEmpty()
+
+fun processCommentLink(param: String?): Map<String, String> = processExternalLink(param) + mapOf("rel" to "ugc nofollow")
+
+fun processCommentImage(url: String, domain: String) = "https://$domain/${url.sanitizeExtImgLink()}"
+
+enum class FactbookFormattingTag(val type: HtmlLexerTag) {
+       B(HtmlTagLexerTag(attributes = mapOf("style" to "font-weight:bold"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       I(HtmlTagLexerTag(attributes = mapOf("style" to "font-style:italic"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       U(HtmlTagLexerTag(attributes = mapOf("style" to "text-decoration:underline"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       S(HtmlTagLexerTag(attributes = mapOf("style" to "text-decoration:line-through"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       SUP(HtmlTagLexerTag(tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::sup.toTagCreator())),
+       SUB(HtmlTagLexerTag(tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::sub.toTagCreator())),
+       COLOR(HtmlTagLexerTag(attributes = ::processColor, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       IPA(HtmlTagLexerTag(attributes = mapOf("style" to "font-family:DejaVu Sans"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       CODE(HtmlTagLexerTag(attributes = mapOf("style" to "font-family:JetBrains Mono"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       CODE_BLOCK(HtmlTagLexerTag(tagCreator = { block ->
+               div {
+                       style = "font-family:JetBrains Mono"
+                       pre {
+                               block()
+                       }
+               }
+       })),
+       BLOCKQUOTE(HtmlTagLexerTag(tagCreator = TagConsumer<*>::blockQuote.toTagCreator())),
+       
+       ERROR(HtmlTagLexerTag(attributes = mapOf("style" to "color: #f00"), tagCreator = TagConsumer<*>::div.toTagCreator())),
+       
+       H1(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h1.toTagCreator()) { null }),
+       H2(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h2.toTagCreator(), ParserTree::treeToAnchorText)),
+       H3(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h3.toTagCreator(), ParserTree::treeToAnchorText)),
+       H4(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h4.toTagCreator(), ParserTree::treeToAnchorText)),
+       H5(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h5.toTagCreator(), ParserTree::treeToAnchorText)),
+       H6(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h6.toTagCreator(), ParserTree::treeToAnchorText)),
+       
+       ALIGN(HtmlTagLexerTag(attributes = ::processAlign, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::div.toTagCreator())),
+       ASIDE(HtmlTagLexerTag(attributes = ::processFloat, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::div.toTagCreator())),
+       
+       DESC(HtmlMetadataLexerTag(absorb = false)),
+       THUMB(HtmlMetadataLexerTag(absorb = true)),
+       
+       IMAGE(HtmlTextBodyLexerTag { _, param, content ->
+               val url = content.sanitizeLink()
+               val (width, height) = getSizeParam(param)
+               val styleValue = getImageSizeStyleValue(width, height)
+               
+               if (url.endsWith(".svg")) {
+                       ({
+                               iframe {
+                                       src = "/assets/images/$url"
+                                       style = styleValue
+                               }
+                       })
+               } else {
+                       ({
+                               script { unsafe { +"window.appendImageThumb(\"/assets/images/$url\", \"$styleValue\");" } }
+                       })
+               }
+       }),
+       MODEL(HtmlTextBodyLexerTag { _, param, content ->
+               val url = content.sanitizeLink()
+               val (width, height) = getSizeParam(param)
+               val sizeStyle = getImageSizeStyleValue(width, height)
+               
+               ({
+                       canvas {
+                               style = sizeStyle
+                               attributes["data-model"] = url
+                       }
+               })
+       }),
+       AUDIO(HtmlTextBodyLexerTag { _, _, content ->
+               val url = content.sanitizeLink()
+               
+               ({
+                       audio {
+                               src = "/assets/sounds/$url"
+                               controls = true
+                       }
+               })
+       }),
+       QUIZ(HtmlTextBodyLexerTag { _, _, content ->
+               val contentJson = JsonStorageCodec.parseToJsonElement(content)
+               
+               ({
+                       script { unsafe { +"window.renderQuiz($contentJson);" } }
+               })
+       }),
+       
+       UL(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::ul.toTagCreator())),
+       OL(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::ol.toTagCreator())),
+       LI(HtmlTagLexerTag(tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::li.toTagCreator())),
+       
+       TABLE(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::table.toTagCreator())),
+       TR(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::tr.toTagCreator())),
+       TD(HtmlTagLexerTag(attributes = ::processTableCell, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::td.toTagCreator())),
+       TH(HtmlTagLexerTag(attributes = ::processTableCell, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::th.toTagCreator())),
+       
+       MOMENT(HtmlTextBodyLexerTag { _, _, content ->
+               val epochMilli = content.toLongOrNull()
+               if (epochMilli == null)
+                       ({ +content })
+               else
+                       ({
+                               span(classes = "moment") {
+                                       style = "display:none"
+                                       +"$epochMilli"
+                               }
+                       })
+       }),
+       LINK(HtmlTagLexerTag(attributes = ::processInternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
+       EXTLINK(HtmlTagLexerTag(attributes = ::processExternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
+       ANCHOR(HtmlTextBodyLexerTag { _, _, content ->
+               val url = content.sanitizeAnchor()
+               
+               ({
+                       a {
+                               id = url
+                               attributes["name"] = url
+                       }
+               })
+       }),
+       REDIRECT(HtmlTextBodyLexerTag { _, _, content ->
+               val url = content.toInternalUrl()
+               val jsString = JsonPrimitive(url).toString()
+               
+               ({
+                       script {
+                               unsafe { +"window.factbookRedirect($jsString);" }
+                       }
+               })
+       }),
+       LANG(
+               HtmlLexerTag { _, param, content ->
+                       if ("tylan".equals(param, ignoreCase = true)) {
+                               val tylan = TylanAlphabetFont.tylanToFontAlphabet(content.treeToText())
+                               ({
+                                       span(classes = "lang-tylan") { +tylan }
+                               })
+                       } else if ("thedish".equals(param, ignoreCase = true)) {
+                               val thedish = content.treeToText()
+                               ({
+                                       span(classes = "lang-thedish") { +thedish }
+                               })
+                       } else if ("kishari".equals(param, ignoreCase = true)) {
+                               val kishari = content.treeToText()
+                               ({
+                                       span(classes = "lang-kishari") { +kishari }
+                               })
+                       } else if ("pokhval".equals(param, ignoreCase = true) || "pochval".equals(param, ignoreCase = true) || "pokhwal".equals(param, ignoreCase = true)) {
+                               val pokhwal = PokhwalishAlphabetFont.pokhwalToFontAlphabet(content.treeToText())
+                               ({
+                                       span(classes = "lang-pokhval") { +pokhwal }
+                               })
+                       } else {
+                               val foreign = content.treeToText()
+                               ({
+                                       +foreign
+                               })
+                       }
+               }
+       ),
+       ALPHABET(
+               HtmlTextBodyLexerTag { _, param, content ->
+                       if ("mechyrdian".equals(content, ignoreCase = true))
+                               ({
+                                       div(classes = "mechyrdia-sans-box") {
+                                               p { +"Input Text:" }
+                                               textArea(classes = "input-box") { spellCheck = false }
+                                               p { +"Font Options:" }
+                                               ul {
+                                                       li {
+                                                               label {
+                                                                       checkBoxInput(classes = "bold-option")
+                                                                       +Entities.nbsp
+                                                                       +"Bold"
+                                                               }
+                                                       }
+                                                       li {
+                                                               label {
+                                                                       checkBoxInput(classes = "ital-option")
+                                                                       +Entities.nbsp
+                                                                       +"Italic"
+                                                               }
+                                                       }
+                                                       li {
+                                                               label {
+                                                                       +"Align"
+                                                                       +Entities.nbsp
+                                                                       select(classes = "align-opts") {
+                                                                               option {
+                                                                                       selected = true
+                                                                                       value = "left"
+                                                                                       +"Left"
+                                                                               }
+                                                                               option {
+                                                                                       value = "center"
+                                                                                       +"Center"
+                                                                               }
+                                                                               option {
+                                                                                       value = "right"
+                                                                                       +"Right"
+                                                                               }
+                                                                       }
+                                                               }
+                                                       }
+                                               }
+                                               p { +"Rendered Text:" }
+                                               img(classes = "output-img") {
+                                                       style = "display:block;max-width:100%"
+                                               }
+                                       }
+                               })
+                       else if ("tylan".equals(content, ignoreCase = true))
+                               ({
+                                       div(classes = "tylan-alphabet-box") {
+                                               p { +"Latin Alphabet:" }
+                                               textArea(classes = "input-box") { spellCheck = false }
+                                               p { +"Tylan Alphabet:" }
+                                               textArea(classes = "output-box lang-tylan") { readonly = true }
+                                       }
+                               })
+                       else if ("thedish".equals(content, ignoreCase = true))
+                               ({
+                                       div(classes = "thedish-alphabet-box") {
+                                               p { +"Latin Alphabet:" }
+                                               textArea(classes = "input-box") { spellCheck = false }
+                                               p { +"Thedish Alphabet:" }
+                                               textArea(classes = "output-box lang-thedish") { readonly = true }
+                                       }
+                               })
+                       else if ("kishari".equals(content, ignoreCase = true))
+                               ({
+                                       div(classes = "kishari-alphabet-box") {
+                                               p { +"Latin Alphabet:" }
+                                               textArea(classes = "input-box") { spellCheck = false }
+                                               p { +"Kishari Alphabet:" }
+                                               textArea(classes = "output-box lang-kishari") { readonly = true }
+                                       }
+                               })
+                       else if ("pokhval".equals(param, ignoreCase = true) || "pochval".equals(param, ignoreCase = true) || "pokhwal".equals(param, ignoreCase = true))
+                               ({
+                                       div(classes = "pokhwal-alphabet-box") {
+                                               p { +"Latin Alphabet:" }
+                                               textArea(classes = "input-box") { spellCheck = false }
+                                               p { +"Pokhwalish Alphabet:" }
+                                               textArea(classes = "output-box lang-pokhwal") { readonly = true }
+                                       }
+                               })
+                       else ({})
+               }
+       ),
+       VOCAB(HtmlTextBodyLexerTag { _, _, content ->
+               val contentJson = JsonStorageCodec.parseToJsonElement(content)
+               
+               ({
+                       script { unsafe { +"window.renderVocab($contentJson);" } }
+               })
+       }),
+       ;
+       
+       companion object {
+               val asTags = LexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+fun ParserTree.toFactbookHtml(): TagConsumer<*>.() -> Any? {
+       return LexerTagEnvironment(
+               Unit,
+               FactbookFormattingTag.asTags,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+       ).processTree(this)
+}
+
+enum class CommentFormattingTag(val type: HtmlLexerTag) {
+       B(FactbookFormattingTag.B.type),
+       I(FactbookFormattingTag.I.type),
+       U(FactbookFormattingTag.U.type),
+       S(FactbookFormattingTag.S.type),
+       SUP(FactbookFormattingTag.SUP.type),
+       SUB(FactbookFormattingTag.SUB.type),
+       COLOR(FactbookFormattingTag.COLOR.type),
+       IPA(FactbookFormattingTag.IPA.type),
+       CODE(FactbookFormattingTag.CODE.type),
+       CODE_BLOCK(FactbookFormattingTag.CODE_BLOCK.type),
+       
+       ERROR(HtmlTagLexerTag(attributes = mapOf("style" to "color: #f00"), tagCreator = TagConsumer<*>::div.toTagCreator())),
+       
+       ALIGN(FactbookFormattingTag.ALIGN.type),
+       ASIDE(FactbookFormattingTag.ASIDE.type),
+       
+       UL(FactbookFormattingTag.UL.type),
+       OL(FactbookFormattingTag.OL.type),
+       LI(FactbookFormattingTag.LI.type),
+       
+       TABLE(FactbookFormattingTag.TABLE.type),
+       TR(FactbookFormattingTag.TR.type),
+       TD(FactbookFormattingTag.TD.type),
+       TH(FactbookFormattingTag.TH.type),
+       
+       URL(HtmlTagLexerTag(attributes = ::processCommentLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
+       
+       LANG(FactbookFormattingTag.LANG.type),
+       
+       IMGBB(HtmlTextBodyLexerTag { _, tagParam, content ->
+               val imageUrl = processCommentImage(content, "i.ibb.co")
+               val (width, height) = getSizeParam(tagParam)
+               val sizeStyle = getImageSizeStyleValue(width, height)
+               
+               ({
+                       img(src = imageUrl) { style = sizeStyle }
+               })
+       }),
+       
+       REPLY(HtmlTextBodyLexerTag { _, _, content ->
+               val id = sanitizeId(content)
+               
+               if (id == null)
+                       ({ +">>$content" })
+               else
+                       ({
+                               a(href = "/comment/view/$id") {
+                                       rel = "ugc"
+                                       +">>$id"
+                               }
+                       })
+       }),
+       
+       QUOTE(FactbookFormattingTag.BLOCKQUOTE.type),
+       EPOCH(FactbookFormattingTag.MOMENT.type),
+       ;
+       
+       companion object {
+               val asTags = LexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+fun ParserTree.toCommentHtml(): TagConsumer<*>.() -> Any? {
+       return LexerTagEnvironment(
+               Unit,
+               CommentFormattingTag.asTags,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+       ).processTree(this)
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserJson.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserJson.kt
new file mode 100644 (file)
index 0000000..e0344dc
--- /dev/null
@@ -0,0 +1,125 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.route.KeyedEnumSerializer
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+@Serializable(with = DocTextColorSerializer::class)
+@JvmInline
+value class DocTextColor(val rgb: Int) {
+       constructor(rgb: String) : this(fromStringOrNull(rgb) ?: error("Expected string of 3 or 6 hex digits with optional # prefix, got $rgb"))
+       
+       override fun toString(): String {
+               return "#${rgb.toString(16).padStart(6, '0')}"
+       }
+       
+       companion object {
+               fun fromStringOrNull(rgb: String): Int? {
+                       return repeatColorDigits(rgb.removePrefix("#"))?.toIntOrNull(16)
+               }
+       }
+}
+
+object DocTextColorSerializer : KSerializer<DocTextColor> {
+       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DocTextColorSerializer", PrimitiveKind.STRING)
+       
+       override fun serialize(encoder: Encoder, value: DocTextColor) {
+               encoder.encodeString(value.toString())
+       }
+       
+       override fun deserialize(decoder: Decoder): DocTextColor {
+               return DocTextColor(decoder.decodeString())
+       }
+}
+
+@Serializable(with = DocTextFontSerializer::class)
+enum class DocTextFont {
+       NORMAL, CODE, IPA
+}
+
+object DocTextFontSerializer : KeyedEnumSerializer<DocTextFont>(DocTextFont.entries)
+
+@Serializable
+data class DocTextFormat(
+       val isBold: Boolean = false,
+       val isItalic: Boolean = false,
+       val isUnderline: Boolean = false,
+       val isStrikeOut: Boolean = false,
+       val isSubscript: Boolean = false,
+       val isSuperscript: Boolean = false,
+       val color: DocTextColor? = null,
+       val font: DocTextFont = DocTextFont.NORMAL
+)
+
+@Serializable
+data class DocText(
+       val text: String,
+       val format: DocTextFormat = DocTextFormat(),
+)
+
+@Serializable
+sealed class DocLayoutItem {
+       @Serializable
+       @SerialName("textLine")
+       data class TextLine(val text: List<DocText>) : DocBlock()
+       
+       @Serializable
+       @SerialName("formatBlock")
+       data class FormatBlock(val blocks: List<DocBlock>) : DocBlock()
+}
+
+@Serializable(with = ListingTypeSerializer::class)
+enum class ListingType {
+       ORDERED,
+       UNORDERED,
+}
+
+object ListingTypeSerializer : KeyedEnumSerializer<ListingType>(ListingType.entries)
+
+@Serializable
+@JvmInline
+value class DocTableRow(
+       val cells: List<DocTableCell>
+)
+
+@Serializable
+data class DocTableCell(
+       val isHeading: Boolean = false,
+       val colSpan: Int = 1,
+       val rowSpan: Int = 1,
+       val contents: DocLayoutItem
+)
+
+@Serializable
+sealed class DocBlock {
+       @Serializable
+       @SerialName("paragraph")
+       data class Paragraph(val contents: List<DocText>) : DocBlock()
+       
+       @Serializable
+       @SerialName("list")
+       data class Listing(val ordering: ListingType, val items: List<DocLayoutItem>) : DocBlock()
+       
+       @Serializable
+       @SerialName("table")
+       data class Table(val items: List<DocTableRow>) : DocBlock()
+}
+
+@Serializable
+data class DocSections(
+       val headingText: String,
+       val headContent: List<DocBlock>,
+       val subSections: List<DocSections>,
+)
+
+@Serializable
+data class Document(
+       val ogData: OpenGraphData?,
+       val sections: DocSections,
+)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserLexer.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserLexer.kt
new file mode 100644 (file)
index 0000000..61cb796
--- /dev/null
@@ -0,0 +1,60 @@
+package info.mechyrdia.lore
+
+class LexerTagEnvironment<TContext, TSubject>(
+       val context: TContext,
+       private val processTags: LexerTags<TContext, TSubject>,
+       private val processText: LexerTextProcessor<TContext, TSubject>,
+       private val processBreak: LexerLineBreakProcessor<TContext, TSubject>,
+       private val processInvalidTag: LexerTagFallback<TContext, TSubject>,
+       private val combiner: LexerCombiner<TContext, TSubject>
+) {
+       fun processTree(parserTree: ParserTree): TSubject {
+               return combiner.processAndCombine(this, parserTree)
+       }
+       
+       fun processNode(parserTreeNode: ParserTreeNode): TSubject {
+               return when (parserTreeNode) {
+                       is ParserTreeNode.Text -> processText.processText(this, parserTreeNode.text)
+                       ParserTreeNode.LineBreak -> processBreak.processLineBreak(this)
+                       is ParserTreeNode.Tag -> processTags[parserTreeNode.tag]?.processTag(this, parserTreeNode.param, parserTreeNode.subNodes)
+                               ?: processInvalidTag.processInvalidTag(this, parserTreeNode.tag, parserTreeNode.param, parserTreeNode.subNodes)
+               }
+       }
+}
+
+@JvmInline
+value class LexerTags<TContext, TSubject> private constructor(private val tags: Map<String, LexerTagProcessor<TContext, TSubject>>) {
+       operator fun get(name: String) = tags[name.lowercase()]
+       
+       operator fun plus(other: LexerTags<TContext, TSubject>) = LexerTags(tags + other.tags)
+       
+       companion object {
+               fun <TContext, TSubject> empty() = LexerTags<TContext, TSubject>(emptyMap())
+               
+               operator fun <TContext, TSubject> invoke(tags: Map<String, LexerTagProcessor<TContext, TSubject>>) = LexerTags(tags.mapKeys { (name, _) -> name.lowercase() })
+       }
+}
+
+fun interface LexerTagProcessor<TContext, TSubject> {
+       fun processTag(env: LexerTagEnvironment<TContext, TSubject>, param: String?, subNodes: ParserTree): TSubject
+}
+
+fun interface LexerTagFallback<TContext, TSubject> {
+       fun processInvalidTag(env: LexerTagEnvironment<TContext, TSubject>, tag: String, param: String?, subNodes: ParserTree): TSubject
+}
+
+fun interface LexerTextProcessor<TContext, TSubject> {
+       fun processText(env: LexerTagEnvironment<TContext, TSubject>, text: String): TSubject
+}
+
+fun interface LexerLineBreakProcessor<TContext, TSubject> {
+       fun processLineBreak(env: LexerTagEnvironment<TContext, TSubject>): TSubject
+}
+
+fun interface LexerCombiner<TContext, TSubject> {
+       fun processAndCombine(env: LexerTagEnvironment<TContext, TSubject>, nodes: ParserTree): TSubject {
+               return combine(env, nodes.map(env::processNode))
+       }
+       
+       fun combine(env: LexerTagEnvironment<TContext, TSubject>, subjects: List<TSubject>): TSubject
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserLexerAsync.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserLexerAsync.kt
new file mode 100644 (file)
index 0000000..6889efc
--- /dev/null
@@ -0,0 +1,68 @@
+package info.mechyrdia.lore
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+
+data class AsyncLexerTagEnvironment<TContext, TSubject>(
+       val context: TContext,
+       private val processTags: AsyncLexerTags<TContext, TSubject>,
+       private val processText: AsyncLexerTextProcessor<TContext, TSubject>,
+       private val processBreak: AsyncLexerLineBreakProcessor<TContext, TSubject>,
+       private val processInvalidTag: AsyncLexerTagFallback<TContext, TSubject>,
+       private val combiner: AsyncLexerCombiner<TContext, TSubject>
+) {
+       suspend fun processTree(parserTree: ParserTree): TSubject {
+               return combiner.processAndCombine(this, parserTree)
+       }
+       
+       suspend fun processNode(parserTreeNode: ParserTreeNode): TSubject {
+               return when (parserTreeNode) {
+                       is ParserTreeNode.Text -> processText.processText(this, parserTreeNode.text)
+                       ParserTreeNode.LineBreak -> processBreak.processLineBreak(this)
+                       is ParserTreeNode.Tag -> processTags[parserTreeNode.tag]?.processTag(this, parserTreeNode.param, parserTreeNode.subNodes)
+                               ?: processInvalidTag.processInvalidTag(this, parserTreeNode.tag, parserTreeNode.param, parserTreeNode.subNodes)
+               }
+       }
+}
+
+@JvmInline
+value class AsyncLexerTags<TContext, TSubject> private constructor(private val tags: Map<String, AsyncLexerTagProcessor<TContext, TSubject>>) {
+       operator fun get(name: String) = tags[name.lowercase()]
+       
+       operator fun plus(other: AsyncLexerTags<TContext, TSubject>) = AsyncLexerTags(tags + other.tags)
+       
+       companion object {
+               fun <TContext, TSubject> empty() = AsyncLexerTags<TContext, TSubject>(emptyMap())
+               
+               operator fun <TContext, TSubject> invoke(tags: Map<String, AsyncLexerTagProcessor<TContext, TSubject>>) = AsyncLexerTags(tags.mapKeys { (name, _) -> name.lowercase() })
+       }
+}
+
+fun interface AsyncLexerTagProcessor<TContext, TSubject> {
+       suspend fun processTag(env: AsyncLexerTagEnvironment<TContext, TSubject>, param: String?, subNodes: ParserTree): TSubject
+}
+
+fun interface AsyncLexerTagFallback<TContext, TSubject> {
+       suspend fun processInvalidTag(env: AsyncLexerTagEnvironment<TContext, TSubject>, tag: String, param: String?, subNodes: ParserTree): TSubject
+}
+
+fun interface AsyncLexerTextProcessor<TContext, TSubject> {
+       suspend fun processText(env: AsyncLexerTagEnvironment<TContext, TSubject>, text: String): TSubject
+}
+
+fun interface AsyncLexerLineBreakProcessor<TContext, TSubject> {
+       suspend fun processLineBreak(env: AsyncLexerTagEnvironment<TContext, TSubject>): TSubject
+}
+
+fun interface AsyncLexerCombiner<TContext, TSubject> {
+       suspend fun processAndCombine(env: AsyncLexerTagEnvironment<TContext, TSubject>, nodes: ParserTree): TSubject {
+               return combine(env, coroutineScope {
+                       nodes.map {
+                               async { env.processNode(it) }
+                       }.awaitAll()
+               })
+       }
+       
+       suspend fun combine(env: AsyncLexerTagEnvironment<TContext, TSubject>, subjects: List<TSubject>): TSubject
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPlain.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPlain.kt
new file mode 100644 (file)
index 0000000..093a91d
--- /dev/null
@@ -0,0 +1,116 @@
+package info.mechyrdia.lore
+
+typealias PlainTextBuilderContext = Unit
+typealias PlainTextBuilderSubject = String
+
+enum class PlainTextTagBehavior {
+       PASS_THROUGH,
+       PASS_THROUGH_SPACED,
+       ABSORB
+}
+
+abstract class PlainTextFormattingProcessor : LexerTagFallback<PlainTextBuilderContext, PlainTextBuilderSubject>, LexerTextProcessor<PlainTextBuilderContext, PlainTextBuilderSubject>, LexerLineBreakProcessor<PlainTextBuilderContext, PlainTextBuilderSubject>, LexerCombiner<PlainTextBuilderContext, PlainTextBuilderSubject> {
+       protected abstract fun getTagBehavior(tag: String): PlainTextTagBehavior
+       protected open fun replaceLineBreak(): String = " "
+       
+       override fun processInvalidTag(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, tag: String, param: String?, subNodes: ParserTree): PlainTextBuilderSubject {
+               return when (getTagBehavior(tag.lowercase())) {
+                       PlainTextTagBehavior.PASS_THROUGH -> env.processTree(subNodes)
+                       PlainTextTagBehavior.PASS_THROUGH_SPACED -> env.processTree(subNodes).let { " $it " }
+                       PlainTextTagBehavior.ABSORB -> ""
+               }
+       }
+       
+       override fun processText(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, text: String): PlainTextBuilderSubject {
+               return text
+       }
+       
+       override fun processLineBreak(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>): PlainTextBuilderSubject {
+               return replaceLineBreak()
+       }
+       
+       override fun combine(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, subjects: List<PlainTextBuilderSubject>): PlainTextBuilderSubject {
+               return subjects.joinToString(separator = "")
+       }
+}
+
+object PlainTextFormattingTag {
+       val asTags = LexerTags.empty<PlainTextBuilderContext, PlainTextBuilderSubject>()
+}
+
+object PlainTextProcessor : PlainTextFormattingProcessor() {
+       private val inlineTags = mapOf(
+               "b" to false,
+               "i" to false,
+               "u" to false,
+               "s" to false,
+               "color" to false,
+               "ipa" to false,
+               "code" to false,
+               "h1" to false,
+               "h2" to false,
+               "h3" to false,
+               "h4" to false,
+               "h5" to false,
+               "h6" to false,
+               "align" to false,
+               "aside" to false,
+               "desc" to false,
+               "link" to false,
+               "extlink" to false,
+               "lang" to false,
+               "sup" to true,
+               "sub" to true,
+               "quote" to true,
+               "blockquote" to true,
+               "ul" to true,
+               "ol" to true,
+               "li" to true,
+               "table" to true,
+               "tr" to true,
+               "td" to true,
+               "th" to true,
+       )
+       
+       override fun getTagBehavior(tag: String): PlainTextTagBehavior {
+               return when (inlineTags[tag]) {
+                       false -> PlainTextTagBehavior.PASS_THROUGH
+                       true -> PlainTextTagBehavior.PASS_THROUGH_SPACED
+                       null -> PlainTextTagBehavior.ABSORB
+               }
+       }
+}
+
+enum class CommentPlainTextFormattingTag(val type: LexerTagProcessor<PlainTextBuilderContext, PlainTextBuilderSubject>) {
+       REPLY(LexerTagProcessor { env, _, subNodes ->
+               val replyContent = env.processTree(subNodes)
+               ">>$replyContent"
+       }),
+       ;
+       
+       companion object {
+               val asTags = LexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+fun ParserTree.toFactbookPlainText(): String {
+       return LexerTagEnvironment(
+               Unit,
+               PlainTextFormattingTag.asTags,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+       ).processTree(this)
+}
+
+fun ParserTree.toCommentPlainText(): String {
+       return LexerTagEnvironment(
+               Unit,
+               CommentPlainTextFormattingTag.asTags,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+       ).processTree(this)
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocess.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocess.kt
new file mode 100644 (file)
index 0000000..db892fd
--- /dev/null
@@ -0,0 +1,479 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.JsonStorageCodec
+import info.mechyrdia.data.StoragePath
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import java.time.Instant
+import kotlin.math.roundToInt
+
+class PreProcessorContext private constructor(
+       val variables: MutableMap<String, ParserTree>,
+       val parent: PreProcessorContext? = null,
+) {
+       constructor(parent: PreProcessorContext? = null, vararg variables: Pair<String, ParserTree>) : this(mutableMapOf(*variables), parent)
+       
+       operator fun get(name: String): ParserTree = variables[name] ?: parent?.get(name) ?: formatErrorToParserTree("Unable to resolve variable $name")
+       
+       operator fun set(name: String, value: ParserTree) {
+               if (parent != null && name in parent)
+                       parent[name] = value
+               else
+                       variables[name] = value
+       }
+       
+       fun setGlobal(name: String, value: ParserTree) {
+               if (parent != null)
+                       parent.setGlobal(name, value)
+               else
+                       variables[name] = value
+       }
+       
+       fun setLocal(name: String, value: ParserTree) {
+               variables[name] = value
+       }
+       
+       operator fun contains(name: String): Boolean = name in variables || (parent?.contains(name) == true)
+       
+       operator fun plus(other: Map<String, ParserTree>) = PreProcessorContext(other.toMutableMap(), this)
+       
+       fun toMap(): Map<String, ParserTree> = parent?.toMap().orEmpty() + variables
+       
+       companion object {
+               operator fun invoke(variables: Map<String, ParserTree>, parent: PreProcessorContext? = null) = PreProcessorContext(variables.toMutableMap(), parent)
+               
+               const val PAGE_PATH_KEY = "PAGE_PATH"
+               const val INSTANT_NOW_KEY = "INSTANT_NOW"
+               
+               context(ApplicationCall)
+               fun defaults() = defaults(StoragePath(request.path()))
+               
+               fun defaults(lorePath: StoragePath) = defaults(lorePath.elements.drop(1))
+               
+               fun defaults(lorePath: List<String>) = mapOf(
+                       PAGE_PATH_KEY to "/${lorePath.joinToString(separator = "/")}".textToTree(),
+                       INSTANT_NOW_KEY to Instant.now().toEpochMilli().toString().textToTree(),
+               )
+       }
+}
+
+typealias PreProcessorSubject = ParserTree
+
+object PreProcessorUtils : AsyncLexerTagFallback<PreProcessorContext, PreProcessorSubject>, AsyncLexerTextProcessor<PreProcessorContext, PreProcessorSubject>, AsyncLexerLineBreakProcessor<PreProcessorContext, PreProcessorSubject>, AsyncLexerCombiner<PreProcessorContext, PreProcessorSubject> {
+       override suspend fun processInvalidTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, tag: String, param: String?, subNodes: ParserTree): PreProcessorSubject {
+               return listOf(
+                       ParserTreeNode.Tag(
+                               tag = tag,
+                               param = param,
+                               subNodes = env.processTree(subNodes)
+                       )
+               )
+       }
+       
+       override suspend fun processText(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, text: String): PreProcessorSubject {
+               return text.textToTree()
+       }
+       
+       override suspend fun processLineBreak(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): PreProcessorSubject {
+               return listOf(ParserTreeNode.LineBreak)
+       }
+       
+       override suspend fun combine(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, subjects: List<PreProcessorSubject>): PreProcessorSubject {
+               return subjects.flatten()
+       }
+       
+       fun withContext(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, newContext: PreProcessorContext): AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject> {
+               return env.copy(context = newContext)
+       }
+       
+       suspend fun processWithContext(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, newContext: PreProcessorContext, input: ParserTree): ParserTree {
+               return withContext(env, newContext).processTree(input)
+       }
+       
+       fun indexTree(tree: ParserTree, index: List<String>): ParserTree {
+               if (index.isEmpty()) return tree
+               val tags = tree.filterIsInstance<ParserTreeNode.Tag>()
+               if (tags.isEmpty()) return formatErrorToParserTree("Cannot index into empty input value")
+               
+               val head = index.first()
+               val tail = index.drop(1)
+               
+               val firstTag = tags.first()
+               return if (firstTag isTag "item" && firstTag.param == null) {
+                       head.toDoubleOrNull()?.roundToInt()?.let { listIndex ->
+                               tree.asPreProcessorList().getOrNull(listIndex)
+                       }?.let { indexTree(it, tail) }.formatError("Index $head is not present in input value")
+               } else if (firstTag isTag "arg" && firstTag.param != null) {
+                       tree.asPreProcessorMap()[head]?.let { indexTree(it, tail) }.formatError("Index $head is not present in input value")
+               } else formatErrorToParserTree("Cannot index into non-collection input value")
+       }
+}
+
+fun interface PreProcessorLexerTag : AsyncLexerTagProcessor<PreProcessorContext, PreProcessorSubject>
+
+inline fun <T : Any> T?.requireParam(tag: String, block: (T) -> ParserTree): ParserTree {
+       return if (this == null)
+               formatErrorToParserTree("Parameter is required for tag $tag")
+       else block(this)
+}
+
+inline fun String?.forbidParam(tag: String, block: () -> ParserTree): ParserTree {
+       return if (this != null)
+               formatErrorToParserTree("Parameter is forbidden for tag $tag")
+       else block()
+}
+
+fun formatErrorToParserTree(error: String): ParserTree {
+       return listOf(ParserTreeNode.Tag("error", null, listOf(ParserTreeNode.Text(error))))
+}
+
+fun ParserTree?.formatError(error: String): ParserTree {
+       return this ?: formatErrorToParserTree(error)
+}
+
+fun ParserTree.isNull() = all { it.isWhitespace() || (it is ParserTreeNode.Tag && it isTag "error") }
+
+fun String.textToTree(): ParserTree = listOf(ParserTreeNode.Text(this))
+
+fun interface PreProcessorFunction {
+       suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree
+}
+
+interface PreProcessorFunctionProvider : PreProcessorLexerTag {
+       val tagName: String
+       
+       suspend fun provideFunction(param: String?): PreProcessorFunction?
+       
+       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, param: String?, subNodes: ParserTree): PreProcessorSubject {
+               return param?.let { provideFunction(it) }.requireParam(tagName) {
+                       val args = subNodes.asPreProcessorMap().mapValuesSuspend { _, value -> env.processTree(value) }
+                       val ctx = PreProcessorContext(args, env.context)
+                       
+                       val func = provideFunction(param) ?: return emptyList()
+                       func.execute(PreProcessorUtils.withContext(env, ctx))
+               }
+       }
+}
+
+abstract class PreProcessorFunctionLibrary(override val tagName: String) : PreProcessorFunctionProvider {
+       abstract val functions: Map<String, PreProcessorFunction>
+       
+       override suspend fun provideFunction(param: String?) = param?.let { functions[it] }
+       
+       companion object {
+               operator fun invoke(tagName: String, library: Map<String, PreProcessorFunction>) = object : PreProcessorFunctionLibrary(tagName) {
+                       override val functions: Map<String, PreProcessorFunction> = library
+               }
+       }
+}
+
+@JvmInline
+value class PreProcessorVariableFunction(private val variable: String) : PreProcessorFunction {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               return env.processTree(env.context[variable])
+       }
+}
+
+object PreProcessorVariableInvoker : PreProcessorFunctionProvider {
+       override val tagName: String = "env"
+       
+       override suspend fun provideFunction(param: String?): PreProcessorFunction? {
+               return param?.let { PreProcessorVariableFunction(it) }
+       }
+}
+
+@JvmInline
+value class PreProcessorScopeFilter(private val variable: String) : PreProcessorFilter {
+       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               return env.copy(context = env.context + env.context[variable].asPreProcessorMap()).processTree(input)
+       }
+}
+
+object PreProcessorScopeInvoker : PreProcessorFilterProvider {
+       override val tagName: String = "scope"
+       
+       override suspend fun provideFilter(param: String?): PreProcessorFilter? {
+               return param?.let { PreProcessorScopeFilter(it) }
+       }
+}
+
+fun interface PreProcessorFilter {
+       suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree
+}
+
+interface PreProcessorFilterProvider : PreProcessorLexerTag {
+       val tagName: String
+       
+       suspend fun provideFilter(param: String?): PreProcessorFilter?
+       
+       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, param: String?, subNodes: ParserTree): PreProcessorSubject {
+               return param?.let { provideFilter(it) }.requireParam(tagName) {
+                       val filter = provideFilter(param) ?: return emptyList()
+                       filter.execute(subNodes, env)
+               }
+       }
+}
+
+abstract class PreProcessorFilterLibrary(override val tagName: String) : PreProcessorFilterProvider {
+       abstract val filters: Map<String, PreProcessorFilter>
+       
+       override suspend fun provideFilter(param: String?) = param?.let { filters[it] }
+       
+       companion object {
+               operator fun invoke(tagName: String, library: Map<String, PreProcessorFilter>) = object : PreProcessorFilterLibrary(tagName) {
+                       override val filters: Map<String, PreProcessorFilter> = library
+               }
+       }
+}
+
+fun ParserTree.asPreProcessorList(): List<ParserTree> = mapNotNull {
+       if (it !is ParserTreeNode.Tag || it isNotTag "item" || it.param != null)
+               null
+       else
+               it.subNodes
+}
+
+fun ParserTree.asPreProcessorMap(): Map<String, ParserTree> = mapNotNull {
+       if (it !is ParserTreeNode.Tag || it isNotTag "arg" || it.param == null)
+               null
+       else
+               it.param to it.subNodes
+}.toMap()
+
+suspend fun <T, R> List<T>.mapSuspend(processor: suspend (T) -> R) = coroutineScope {
+       map {
+               async {
+                       processor(it)
+               }
+       }.awaitAll()
+}
+
+suspend fun <K, V, R> Map<K, V>.mapValuesSuspend(processor: suspend (K, V) -> R) = coroutineScope {
+       map { (k, v) ->
+               async {
+                       k to processor(k, v)
+               }
+       }.awaitAll().toMap()
+}
+
+enum class PreProcessorTags(val type: PreProcessorLexerTag) {
+       EVAL(PreProcessorLexerTag { env, param, subNodes ->
+               val times = param?.toDoubleOrNull()?.roundToInt() ?: 1
+               
+               var tree = subNodes
+               repeat(times) {
+                       tree = env.processTree(tree)
+               }
+               tree
+       }),
+       LAZY(PreProcessorLexerTag { _, param, subNodes ->
+               param.forbidParam("lazy") { subNodes }
+       }),
+       VAL(PreProcessorLexerTag { env, param, subNodes ->
+               param.forbidParam("val") {
+                       env.processTree(subNodes).treeToText().textToTree()
+               }
+       }),
+       VAR(PreProcessorLexerTag { env, param, subNodes ->
+               param.forbidParam("var") {
+                       env.context[env.processTree(subNodes).treeToText()]
+               }
+       }),
+       DEFAULT(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("var") { varName ->
+                       if (varName in env.context)
+                               env.context[varName]
+                       else env.processTree(subNodes)
+               }
+       }),
+       SET_PARAM(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("var") { varName ->
+                       val paramValue = env.context[varName].treeToText()
+                       val withParams = subNodes.map { node ->
+                               if (node is ParserTreeNode.Tag && node.param == null)
+                                       node.copy(param = paramValue)
+                               else node
+                       }
+                       env.processTree(withParams)
+               }
+       }),
+       ENV(PreProcessorVariableInvoker),
+       SCOPE(PreProcessorScopeInvoker),
+       SET(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("set") { varName ->
+                       env.context[varName] = env.processTree(subNodes)
+                       emptyList()
+               }
+       }),
+       SET_GLOBAL(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("set_global") { varName ->
+                       env.context.setGlobal(varName, env.processTree(subNodes))
+                       emptyList()
+               }
+       }),
+       SET_LOCAL(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("set_local") { varName ->
+                       env.context.setLocal(varName, env.processTree(subNodes))
+                       emptyList()
+               }
+       }),
+       INDEX(PreProcessorLexerTag { env, param, subNodes ->
+               val inputList = env.processTree(subNodes).asPreProcessorList()
+               
+               (param?.toDoubleOrNull() ?: param?.let {
+                       env.processTree(env.context[param]).treeToNumberOrNull(String::toDoubleOrNull)
+               })?.roundToInt().requireParam("index") { index ->
+                       inputList.getOrNull(index).formatError("Index $index is not present in input list")
+               }
+       }),
+       MEMBER(PreProcessorLexerTag { env, param, subNodes ->
+               param?.split('.').requireParam("member") { index ->
+                       PreProcessorUtils.indexTree(env.processTree(subNodes), index)
+               }
+       }),
+       FOR_EACH(PreProcessorLexerTag { env, param, subNodes ->
+               val itemToContext: (ParserTree) -> Map<String, ParserTree> = if (param == null)
+                       ParserTree::asPreProcessorMap
+               else ({ mapOf(param to it) })
+               
+               val subTags = subNodes.filterIsInstance<ParserTreeNode.Tag>()
+               val list = subTags.singleOrNull { it isTag "in" }?.subNodes
+                       ?.let { env.processTree(it) }
+                       ?.asPreProcessorList()
+               
+               val body = subTags.singleOrNull { it isTag "do" }?.subNodes
+               if (list != null && body != null)
+                       list.mapSuspend { item ->
+                               PreProcessorUtils.processWithContext(env, env.context + itemToContext(item), body)
+                       }.flatten()
+               else formatErrorToParserTree("Expected child tag [in] to take list input and child tag [do] to take loop body")
+       }),
+       MAP(PreProcessorLexerTag { env, param, subNodes ->
+               val itemToContext: (ParserTree) -> Map<String, ParserTree> = if (param == null)
+                       ParserTree::asPreProcessorMap
+               else ({ mapOf(param to it) })
+               
+               val subTags = subNodes.filterIsInstance<ParserTreeNode.Tag>()
+               val list = subTags.singleOrNull { it isTag "in" }?.subNodes
+                       ?.let { env.processTree(it) }
+                       ?.asPreProcessorList()
+               
+               val body = subTags.singleOrNull { it isTag "do" }?.subNodes
+               if (list != null && body != null)
+                       list.mapSuspend { item ->
+                               ParserTreeNode.Tag("item", null, PreProcessorUtils.processWithContext(env, env.context + itemToContext(item), body))
+                       }
+               else formatErrorToParserTree("Expected child tag [in] to take list input and child tag [do] to take loop body")
+       }),
+       IF(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("if") { boolVar ->
+                       env.context[boolVar].treeToBooleanOrNull()?.let {
+                               if (it) env.processTree(subNodes) else emptyList()
+                       }.formatError("Expected variable $boolVar to contain boolean value")
+               }
+       }),
+       UNLESS(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("unless") { boolVar ->
+                       env.context[boolVar].treeToBooleanOrNull()?.let {
+                               if (it) emptyList() else env.processTree(subNodes)
+                       }.formatError("Expected variable $boolVar to contain boolean value")
+               }
+       }),
+       LET(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("let") { varName ->
+                       if (varName in env.context && !env.context[varName].isNull())
+                               env.processTree(subNodes)
+                       else emptyList()
+               }
+       }),
+       FALLBACK(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("fallback") { varName ->
+                       if (varName !in env.context || env.context[varName].isNull())
+                               env.processTree(subNodes)
+                       else emptyList()
+               }
+       }),
+       MATH(PreProcessorMathOperators),
+       LOGIC(PreProcessorLogicOperator),
+       TEST(PreProcessorInputTest),
+       JSON_PARSE(PreProcessorLexerTag { _, param, subNodes ->
+               param.forbidParam("json_parse") {
+                       JsonStorageCodec.parseToJsonElement(subNodes.treeToText()).toPreProcessTree()
+               }
+       }),
+       JSON_STRINGIFY(PreProcessorLexerTag { env, param, subNodes ->
+               param.forbidParam("json_stringify") {
+                       env.processTree(subNodes).toPreProcessJson().toString().textToTree()
+               }
+       }),
+       FUNCTION(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("function") { scriptName ->
+                       PreProcessorScriptLoader.runScriptSafe(scriptName, env.processTree(subNodes).asPreProcessorMap(), env) {
+                               it.renderInBBCode()
+                       }
+               }
+       }),
+       FILTER(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("filter") { scriptName ->
+                       PreProcessorScriptLoader.runScriptSafe(scriptName, env.processTree(subNodes), env) {
+                               it.renderInBBCode()
+                       }
+               }
+       }),
+       WITH_DATA_FILE(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("with_data_file") { dataFileName ->
+                       try {
+                               val args = FactbookLoader.loadFactbookContext(dataFileName.split('/'))
+                               env.copy(context = env.context + args).processTree(subNodes)
+                       } catch (ex: Exception) {
+                               ex.renderInBBCode()
+                       }
+               }
+       }),
+       IMPORT(PreProcessorLexerTag { env, param, subNodes ->
+               param.requireParam("import") { templateName ->
+                       PreProcessorTemplateLoader.runTemplateWith(templateName, env.processTree(subNodes).asPreProcessorMap())
+               }
+       }),
+       INCLUDE(PreProcessorLexerTag { env, param, subNodes ->
+               param.forbidParam("include") {
+                       PreProcessorTemplateLoader.runTemplateHere(env.processTree(subNodes).treeToText(), env)
+               }
+       }),
+       TEMPLATE(PreProcessorLexerTag { env, param, subNodes ->
+               param.forbidParam("template") {
+                       PreProcessorTemplateLoader.loadTemplate(env.processTree(subNodes).treeToText())
+               }
+       }),
+       ;
+       
+       companion object {
+               val asTags = AsyncLexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+suspend fun ParserTree.preProcess(context: PreProcessorContext): ParserTree {
+       return AsyncLexerTagEnvironment(
+               context,
+               PreProcessorTags.asTags,
+               PreProcessorUtils,
+               PreProcessorUtils,
+               PreProcessorUtils,
+               PreProcessorUtils,
+       ).processTree(this)
+}
+
+fun Exception.renderInBBCode(): ParserTree = listOf(
+       ParserTreeNode.Tag("error", null, listOf(
+               ParserTreeNode.Tag("b", null, listOf(ParserTreeNode.Text("${this::class.qualifiedName}: $message"))),
+               ParserTreeNode.LineBreak,
+               ParserTreeNode.Tag("ul", null,
+                       stackTraceToString().split(System.lineSeparator()).map {
+                               ParserTreeNode.Tag("li", null, listOf(ParserTreeNode.Text(it)))
+                       }
+               ),
+       )),
+)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt
new file mode 100644 (file)
index 0000000..d7ef1cf
--- /dev/null
@@ -0,0 +1,143 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.JsonStorageCodec
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import io.ktor.util.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.*
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+import java.util.function.Consumer
+import javax.script.Compilable
+import javax.script.CompiledScript
+import javax.script.ScriptEngineManager
+import javax.script.SimpleBindings
+import kotlin.coroutines.*
+
+object PreProcessorTemplateLoader {
+       suspend fun loadTemplate(name: String): ParserTree {
+               val templateFile = StoragePath.templateDir / "$name.tpl"
+               val template = FileStorage.instance.readFile(templateFile) ?: return emptyList()
+               return ParserState.parseText(String(template))
+       }
+       
+       suspend fun runTemplateWith(name: String, args: Map<String, ParserTree>): ParserTree {
+               return loadTemplate(name).preProcess(PreProcessorContext(args))
+       }
+       
+       suspend fun runTemplateHere(name: String, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               return env.processTree(loadTemplate(name))
+       }
+}
+
+object PreProcessorScriptLoader {
+       private val scriptEngine = ThreadLocal.withInitial { ScriptEngineManager().getEngineByExtension("groovy") }
+       private val hasher = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
+       private val cache = ConcurrentHashMap<String, CompiledScript>()
+       
+       suspend fun loadFunction(name: String): CompiledScript? {
+               val scriptFile = StoragePath.scriptDir / "$name.groovy"
+               val script = FileStorage.instance.readFile(scriptFile) ?: return null
+               
+               val digest = hex(hasher.get().digest(script))
+               return withContext(Dispatchers.IO) {
+                       cache.computeIfAbsent(digest) { _ ->
+                               (scriptEngine.get() as Compilable).compile(String(script))
+                       }
+               }
+       }
+       
+       fun jsonToGroovy(json: JsonElement): Any? = when (json) {
+               JsonNull -> null
+               is JsonPrimitive -> if (json.isString)
+                       json.content
+               else
+                       json.booleanOrNull ?: json.intOrNull ?: json.double
+               
+               is JsonObject -> json.mapValues { (_, it) -> jsonToGroovy(it) }
+               is JsonArray -> json.map { jsonToGroovy(it) }
+       }
+       
+       fun groovyToJson(data: Any?): JsonElement = when (data) {
+               null -> JsonNull
+               is String -> JsonPrimitive(data)
+               is Number -> JsonPrimitive(data)
+               is Boolean -> JsonPrimitive(data)
+               is List<*> -> JsonArray(data.map { groovyToJson(it) })
+               is Set<*> -> JsonArray(data.map { groovyToJson(it) })
+               is Map<*, *> -> JsonObject(data.map { (k, v) -> k.toString() to groovyToJson(v) }.toMap())
+               else -> throw ClassCastException("Expected null, String, Number, Boolean, List, Set, or Map for converted data, got $data of type ${data::class.qualifiedName}")
+       }
+       
+       suspend fun runScriptInternal(script: CompiledScript, bind: Map<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): Any? {
+               return suspendCancellableCoroutine { continuation ->
+                       val bindings = SimpleBindings()
+                       bindings.putAll(bind)
+                       bindings["stdlib"] = PreProcessorScriptStdlib(env, continuation.context, continuation::resumeWithException)
+                       bindings["ctx"] = PreProcessorScriptVarContext { jsonToGroovy(env.context[it].toPreProcessJson()) }
+                       bindings["finish"] = Consumer<Any?>(continuation::resume)
+                       
+                       script.eval(bindings)
+               }
+       }
+       
+       private suspend fun runScriptWithBindings(scriptName: String, bind: Map<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
+               return try {
+                       val script = loadFunction(scriptName)!!
+                       val result = runScriptInternal(script, bind, env)
+                       return if (result is String)
+                               ParserState.parseText(result)
+                       else
+                               groovyToJson(result).toPreProcessTree()
+               } catch (ex: Exception) {
+                       errorHandler(ex)
+               }
+       }
+       
+       suspend fun runScriptSafe(scriptName: String, args: Map<String, ParserTree>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
+               val groovyArgs = args.mapValuesTo(mutableMapOf()) { (_, it) -> jsonToGroovy(it.toPreProcessJson()) }
+               return runScriptWithBindings(scriptName, mapOf("args" to groovyArgs), env, errorHandler)
+       }
+       
+       suspend fun runScriptSafe(scriptName: String, input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
+               return runScriptWithBindings(scriptName, mapOf("text" to input.unparse()), env, errorHandler)
+       }
+}
+
+fun interface PreProcessorScriptVarContext {
+       operator fun get(name: String): Any?
+}
+
+class PreProcessorScriptStdlib(private val env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, private val context: CoroutineContext, private val onError: (Throwable) -> Unit) {
+       fun jsonStringify(data: Any?): String {
+               return PreProcessorScriptLoader.groovyToJson(data).toString()
+       }
+       
+       fun jsonParse(json: String): Any? {
+               return PreProcessorScriptLoader.jsonToGroovy(JsonStorageCodec.parseToJsonElement(json))
+       }
+       
+       fun runScript(scriptName: String, args: Map<String, Any?>, useResult: Consumer<Any?>) {
+               suspend {
+                       val script = PreProcessorScriptLoader.loadFunction(scriptName)!!
+                       val argsMutable = if (args is MutableMap) args else args.toMutableMap()
+                       PreProcessorScriptLoader.runScriptInternal(script, argsMutable, env)
+               }.startCoroutine(Continuation(context) { result ->
+                       result.onSuccess(useResult::accept)
+                       result.onFailure(onError)
+               })
+       }
+       
+       fun runScript(scriptName: String, useResult: Consumer<Any?>) {
+               suspend {
+                       val script = PreProcessorScriptLoader.loadFunction(scriptName)!!
+                       PreProcessorScriptLoader.runScriptInternal(script, mutableMapOf(), env)
+               }.startCoroutine(Continuation(context) { result ->
+                       result.onSuccess(useResult::accept)
+                       result.onFailure(onError)
+               })
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt
new file mode 100644 (file)
index 0000000..d688147
--- /dev/null
@@ -0,0 +1,83 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.JsonStorageCodec
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import kotlinx.serialization.json.*
+
+fun JsonElement.toPreProcessTree(): ParserTree = when (this) {
+       JsonNull -> emptyList()
+       
+       is JsonPrimitive -> if (isString)
+               ParserState.parseText(content)
+       else listOf(ParserTreeNode.Text(content))
+       
+       is JsonArray -> map {
+               ParserTreeNode.Tag("item", null, it.toPreProcessTree())
+       }
+       
+       is JsonObject -> map {
+               ParserTreeNode.Tag("arg", it.key, it.value.toPreProcessTree())
+       }
+}
+
+fun ParserTreeNode.unparse(): String = when (this) {
+       is ParserTreeNode.Text -> text
+       ParserTreeNode.LineBreak -> "\n\n"
+       is ParserTreeNode.Tag -> buildString {
+               append("[")
+               append(tag)
+               param?.let {
+                       append("=")
+                       append(it)
+               }
+               append("]")
+               
+               append(subNodes.unparse())
+               
+               append("[/")
+               append(tag)
+               append("]")
+       }
+}
+
+fun ParserTree.unparse() = joinToString(separator = "") { it.unparse() }
+
+fun ParserTree.toPreProcessJson(): JsonElement {
+       val noBlanks = filterNot { it.isWhitespace() }
+       return if (noBlanks.all { it is ParserTreeNode.Tag && it isTag "item" && it.param == null })
+               JsonArray(asPreProcessorList().map { it.toPreProcessJson() })
+       else if (noBlanks.all { it is ParserTreeNode.Tag && it isTag "arg" && it.param != null })
+               JsonObject(asPreProcessorMap().mapValues { (_, it) -> it.toPreProcessJson() })
+       else if (noBlanks.size == 1)
+               when (val node = noBlanks.single()) {
+                       is ParserTreeNode.Text -> JsonPrimitive(node.text)
+                       ParserTreeNode.LineBreak -> JsonPrimitive("\n\n")
+                       is ParserTreeNode.Tag -> if (node isTag "val" && node.param == null) {
+                               val value = node.subNodes.treeToText()
+                               value.toBooleanStrictOrNull()?.let { JsonPrimitive(it) }
+                                       ?: value.toDoubleOrNull()?.let { JsonPrimitive(it) }
+                                       ?: JsonPrimitive(value)
+                       } else JsonPrimitive(node.unparse())
+               }
+       else JsonPrimitive(unparse())
+}
+
+object FactbookLoader {
+       suspend fun loadJsonData(lorePath: List<String>): JsonObject {
+               val jsonPath = lorePath.dropLast(1) + listOf("${lorePath.last()}.json")
+               val bytes = FileStorage.instance.readFile(StoragePath.jsonDocDir / jsonPath) ?: return JsonObject(emptyMap())
+               return JsonStorageCodec.parseToJsonElement(String(bytes)) as JsonObject
+       }
+       
+       suspend fun loadFactbookContext(lorePath: List<String>): Map<String, ParserTree> {
+               return loadJsonData(lorePath).mapValues { (_, it) -> it.toPreProcessTree() }
+       }
+       
+       suspend fun loadFactbook(lorePath: List<String>): ParserTree? {
+               val filePath = StoragePath.articleDir / lorePath
+               val bytes = FileStorage.instance.readFile(filePath) ?: return null
+               val inputTree = ParserState.parseText(String(bytes))
+               return inputTree.preProcess(PreProcessorContext(loadFactbookContext(lorePath) + PreProcessorContext.defaults(lorePath)))
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessMath.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessMath.kt
new file mode 100644 (file)
index 0000000..afc0f3c
--- /dev/null
@@ -0,0 +1,230 @@
+package info.mechyrdia.lore
+
+import java.time.Instant
+import kotlin.math.*
+
+fun <T : Number> ParserTree.treeToNumberOrNull(convert: String.() -> T?) = treeToText().convert()
+
+fun ParserTree.treeToBooleanOrNull() = when (treeToText().lowercase()) {
+       "true" -> true
+       "false" -> false
+       else -> null
+}
+
+fun Number.numberToTree(): ParserTree = listOf(ParserTreeNode.Tag("val", null, "%f".format(toDouble()).textToTree()))
+
+fun Boolean.booleanToTree(): ParserTree = listOf(ParserTreeNode.Tag("val", null, toString().textToTree()))
+
+object PreProcessorMathOperators : PreProcessorFunctionLibrary("math") {
+       override val functions: Map<String, PreProcessorFunction> = mapOf(
+               "neg" to PreProcessorMathUnaryOperator(Double::unaryMinus),
+               "sin" to PreProcessorMathUnaryOperator(::sin),
+               "cos" to PreProcessorMathUnaryOperator(::cos),
+               "tan" to PreProcessorMathUnaryOperator(::tan),
+               "asin" to PreProcessorMathUnaryOperator(::asin),
+               "acos" to PreProcessorMathUnaryOperator(::acos),
+               "atan" to PreProcessorMathUnaryOperator(::atan),
+               "sqrt" to PreProcessorMathUnaryOperator(::sqrt),
+               "cbrt" to PreProcessorMathUnaryOperator(::cbrt),
+               "ceil" to PreProcessorMathUnaryOperator(::ceil),
+               "floor" to PreProcessorMathUnaryOperator(::floor),
+               "trunc" to PreProcessorMathUnaryOperator(::truncate),
+               "round" to PreProcessorMathUnaryOperator(::round),
+               
+               "add" to PreProcessorMathBinaryOperator(Double::plus),
+               "sub" to PreProcessorMathBinaryOperator(Double::minus),
+               "mul" to PreProcessorMathBinaryOperator(Double::times),
+               "div" to PreProcessorMathBinaryOperator(Double::div),
+               "mod" to PreProcessorMathBinaryOperator(Double::mod),
+               "pow" to PreProcessorMathBinaryOperator(Double::pow),
+               "log" to PreProcessorMathBinaryOperator(::log),
+               "min" to PreProcessorMathBinaryOperator(::min),
+               "max" to PreProcessorMathBinaryOperator(::max),
+               "hypot" to PreProcessorMathBinaryOperator(::hypot),
+               "atan2" to PreProcessorMathBinaryOperator(::atan2),
+               
+               "min" to PreProcessorMathVariadicOperator(List<Double>::min),
+               "max" to PreProcessorMathVariadicOperator(List<Double>::max),
+               "sum" to PreProcessorMathVariadicOperator(List<Double>::sum),
+               "prod" to PreProcessorMathVariadicOperator { it.fold(1.0, Double::times) },
+               "mean" to PreProcessorMathVariadicOperator { it.sum() / it.size.coerceAtLeast(1) },
+               
+               "eq" to PreProcessorMathPredicate { left, right -> left == right },
+               "lt" to PreProcessorMathPredicate { left, right -> left < right },
+               "gt" to PreProcessorMathPredicate { left, right -> left > right },
+               "ne" to PreProcessorMathPredicate { left, right -> left != right },
+               "le" to PreProcessorMathPredicate { left, right -> left <= right },
+               "ge" to PreProcessorMathPredicate { left, right -> left >= right },
+       )
+}
+
+fun interface PreProcessorMathUnaryOperator : PreProcessorFunction {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               val input = env.processTree(env.context["in"])
+               
+               return input.treeToNumberOrNull(String::toDoubleOrNull)
+                       ?.let { calculate(it) }
+                       ?.numberToTree()
+                       .formatError("Math operations require numerical inputs, got ${input.unparse()}")
+       }
+       
+       fun calculate(input: Double): Double
+}
+
+fun interface PreProcessorMathBinaryOperator : PreProcessorFunction {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               val leftValue = env.processTree(env.context["left"])
+               val rightValue = env.processTree(env.context["right"])
+               
+               val left = leftValue.treeToNumberOrNull(String::toDoubleOrNull)
+               val right = rightValue.treeToNumberOrNull(String::toDoubleOrNull)
+               
+               if (left == null || right == null)
+                       return formatErrorToParserTree("Math operations require numerical inputs, got ${leftValue.unparse()} and ${rightValue.unparse()}")
+               
+               return calculate(left, right).numberToTree()
+       }
+       
+       fun calculate(left: Double, right: Double): Double
+}
+
+fun interface PreProcessorMathVariadicOperator : PreProcessorFunction {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               val argsList = env.processTree(env.context["in"])
+               val args = argsList.asPreProcessorList().mapNotNull { it.treeToNumberOrNull(String::toDoubleOrNull) }
+               
+               if (args.isEmpty() && argsList.isNotEmpty())
+                       return formatErrorToParserTree("Math operations require numerical inputs, got ${argsList.unparse()}")
+               
+               return calculate(args).numberToTree()
+       }
+       
+       fun calculate(args: List<Double>): Double
+}
+
+fun interface PreProcessorMathPredicate : PreProcessorFunction {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               val leftValue = env.processTree(env.context["left"])
+               val rightValue = env.processTree(env.context["right"])
+               
+               val left = leftValue.treeToNumberOrNull(String::toDoubleOrNull)
+               val right = rightValue.treeToNumberOrNull(String::toDoubleOrNull)
+               
+               if (left == null || right == null)
+                       return formatErrorToParserTree("Math operations require numerical inputs, got ${leftValue.unparse()} and ${rightValue.unparse()}")
+               
+               return calculate(left, right).booleanToTree()
+       }
+       
+       fun calculate(left: Double, right: Double): Boolean
+}
+
+fun interface PreProcessorLogicBinaryOperator : PreProcessorFunction {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               val leftValue = env.processTree(env.context["left"])
+               val rightValue = env.processTree(env.context["right"])
+               
+               val left = leftValue.treeToBooleanOrNull()
+               val right = rightValue.treeToBooleanOrNull()
+               
+               if (left == null || right == null)
+                       return formatErrorToParserTree("Logical operations require boolean inputs, got ${leftValue.unparse()} and ${rightValue.unparse()}")
+               
+               return calculate(left, right).booleanToTree()
+       }
+       
+       fun calculate(left: Boolean, right: Boolean): Boolean
+}
+
+fun interface PreProcessorLogicOperator : PreProcessorFunction {
+       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               val argsList = env.processTree(env.context["in"])
+               val args = argsList.asPreProcessorList().mapNotNull { it.treeToBooleanOrNull() }
+               
+               if (args.isEmpty() && argsList.isNotEmpty())
+                       return formatErrorToParserTree("Logical operations require boolean inputs, got ${argsList.unparse()}")
+               
+               return calculate(args).booleanToTree()
+       }
+       
+       fun calculate(inputs: List<Boolean>): Boolean
+       
+       companion object : PreProcessorFunctionLibrary("logic") {
+               override val functions: Map<String, PreProcessorFunction> = mapOf(
+                       "not" to PreProcessorFunction { env ->
+                               val input = env.processTree(env.context["in"])
+                               
+                               input
+                                       .treeToBooleanOrNull()
+                                       ?.let { "${!it}".textToTree() }
+                                       .formatError("Logical operations require boolean inputs, got ${input.unparse()}")
+                       },
+                       
+                       "and" to PreProcessorLogicBinaryOperator { left, right -> left && right },
+                       "or" to PreProcessorLogicBinaryOperator { left, right -> left || right },
+                       "xor" to PreProcessorLogicBinaryOperator { left, right -> left xor right },
+                       "nand" to PreProcessorLogicBinaryOperator { left, right -> !(left && right) },
+                       "nor" to PreProcessorLogicBinaryOperator { left, right -> !(left || right) },
+                       "xnor" to PreProcessorLogicBinaryOperator { left, right -> !(left xor right) },
+                       "implies" to PreProcessorLogicBinaryOperator { left, right -> left || !right },
+                       
+                       "all" to PreProcessorLogicOperator { inputs -> inputs.all { it } },
+                       "any" to PreProcessorLogicOperator { inputs -> inputs.any { it } },
+                       "not_all" to PreProcessorLogicOperator { inputs -> inputs.any { !it } },
+                       "none" to PreProcessorLogicOperator { inputs -> inputs.none { it } },
+                       "count" to PreProcessorFunction { env ->
+                               val argsList = env.processTree(env.context["in"])
+                               val args = argsList.asPreProcessorList().mapNotNull { it.treeToBooleanOrNull() }
+                               
+                               if (args.isEmpty() && argsList.isNotEmpty())
+                                       formatErrorToParserTree("Logical operations require boolean inputs, got ${argsList.unparse()}")
+                               else
+                                       args.count { it }.numberToTree()
+                       },
+               )
+       }
+}
+
+fun interface PreProcessorFormatter : PreProcessorFilter {
+       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               return calculate(input.treeToText())
+       }
+       
+       fun calculate(input: String): ParserTree
+       
+       companion object : PreProcessorFilterLibrary("format") {
+               override val filters: Map<String, PreProcessorFilter> = mapOf(
+                       "iso_instant" to PreProcessorFormatter {
+                               it.toLongOrNull()
+                                       ?.let { long ->
+                                               Instant.ofEpochMilli(long).toString().textToTree()
+                                       }.formatError("ISO Instant values must be formatted as base-10 long values, got $it")
+                       },
+                       "local_instant" to PreProcessorFormatter {
+                               it.toLongOrNull()
+                                       ?.let { long ->
+                                               listOf(ParserTreeNode.Tag("moment", null, listOf(ParserTreeNode.Text(long.toString()))))
+                                       }.formatError("ISO Instant values must be formatted as base-10 long values, got $it")
+                       },
+               )
+       }
+}
+
+fun interface PreProcessorInputTest : PreProcessorFilter {
+       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
+               return calculate(input).booleanToTree()
+       }
+       
+       fun calculate(input: ParserTree): Boolean
+       
+       companion object : PreProcessorFilterLibrary("test") {
+               override val filters: Map<String, PreProcessorFilter> = mapOf(
+                       "null" to PreProcessorInputTest { it.isNull() },
+                       "empty" to PreProcessorInputTest { it.isEmpty() },
+                       "blank" to PreProcessorInputTest { it.isWhitespace() },
+                       "not_null" to PreProcessorInputTest { !it.isNull() },
+                       "not_empty" to PreProcessorInputTest { it.isNotEmpty() },
+                       "not_blank" to PreProcessorInputTest { !it.isWhitespace() },
+               )
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRaw.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRaw.kt
new file mode 100644 (file)
index 0000000..0356bca
--- /dev/null
@@ -0,0 +1,142 @@
+package info.mechyrdia.lore
+
+import io.ktor.util.*
+import kotlinx.html.*
+import java.time.Instant
+
+fun String.toRawLink() = substringBeforeLast('#').sanitizeLink().toInternalUrl() + "?format=raw"
+
+fun processRawInternalLink(param: String?): Map<String, String> = param
+       ?.toRawLink()
+       ?.let { mapOf("href" to it) }
+       .orEmpty()
+
+fun processRawLanguage(param: String?): Map<String, String> = mapOf("data-lang" to (param ?: "foreign"))
+
+private class HtmlDataFormatTag(val dataFormat: String) : HtmlLexerTag {
+       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               val content = HtmlLexerProcessor.combineInline(env, subNodes)
+               
+               return {
+                       span {
+                               attributes["data-format"] = dataFormat
+                               +content
+                       }
+               }
+       }
+}
+
+private class HtmlNotSupportedInRawViewTag(val message: String) : HtmlLexerTag {
+       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               return { p { +message } }
+       }
+}
+
+enum class RawFactbookFormattingTag(val type: HtmlLexerTag) {
+       B(HtmlDataFormatTag("b")),
+       I(HtmlDataFormatTag("i")),
+       U(HtmlDataFormatTag("u")),
+       S(HtmlDataFormatTag("s")),
+       IPA(HtmlDataFormatTag("ipa")),
+       CODE(HtmlDataFormatTag("code")),
+       CODE_BLOCK(HtmlLexerTag { env, _, subNodes ->
+               val content = HtmlLexerProcessor.combineInline(env, subNodes)
+               ({
+                       div {
+                               attributes["data-format"] = "code"
+                               pre { +content }
+                       }
+               })
+       }),
+       ERROR(HtmlLexerTag { env, _, subNodes ->
+               val content = HtmlLexerProcessor.combineInline(env, subNodes)
+               ({
+                       div {
+                               attributes["data-format"] = "error"
+                               +content
+                       }
+               })
+       }),
+       H1(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h1.toTagCreator())),
+       H2(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h2.toTagCreator())),
+       H3(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h3.toTagCreator())),
+       H4(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h4.toTagCreator())),
+       H5(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h5.toTagCreator())),
+       H6(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h6.toTagCreator())),
+       ALIGN(HtmlLexerTag { env, param, subNodes ->
+               val alignments = setOf("left", "center", "right", "justify")
+               val alignment = param?.lowercase()?.takeIf { it in alignments }
+               val content = HtmlLexerProcessor.combineBlock(env, subNodes)
+               
+               ({
+                       div {
+                               alignment?.let { attributes["data-align"] = it }
+                               +content
+                       }
+               })
+       }),
+       ASIDE(HtmlLexerTag { env, param, subNodes ->
+               val alignments = setOf("left", "right")
+               val alignment = param?.lowercase()?.takeIf { it in alignments }
+               val content = HtmlLexerProcessor.combineBlock(env, subNodes)
+               
+               ({
+                       div {
+                               alignment?.let { attributes["data-aside"] = it }
+                               +content
+                       }
+               })
+       }),
+       IMAGE(HtmlTextBodyLexerTag { _, param, content ->
+               val url = content.sanitizeLink()
+               val (width, height) = getSizeParam(param)
+               val styleValue = getRawImageSizeStyleValue(width, height)
+               
+               ({
+                       img(src = "/assets/images/$url") {
+                               width?.let { attributes["data-width"] = "$it" }
+                               height?.let { attributes["data-height"] = "$it" }
+                               style = styleValue
+                       }
+               })
+       }),
+       MODEL(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive 3D model views")),
+       QUIZ(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive quizzes")),
+       MOMENT(HtmlTextBodyLexerTag { _, _, content ->
+               val epochMilli = content.toLongOrNull()
+               if (epochMilli == null)
+                       ({ +content })
+               else
+                       ({ +Instant.ofEpochMilli(epochMilli).toString() })
+       }),
+       LINK(HtmlTagLexerTag(attributes = ::processRawInternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
+       REDIRECT(HtmlTextBodyLexerTag { _, _, content ->
+               val url = content.toRawLink()
+               
+               ({
+                       a(href = url) { +"Manual page redirect" }
+               })
+       }),
+       LANG(HtmlTagLexerTag(attributes = ::processRawLanguage, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
+       
+       ALPHABET(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive constructed script previews")),
+       VOCAB(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive constructed language dictionaries")),
+       ;
+       
+       companion object {
+               val asTags = FactbookFormattingTag.asTags + LexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+fun ParserTree.toRawHtml(): TagConsumer<*>.() -> Any? {
+       return LexerTagEnvironment(
+               Unit,
+               RawFactbookFormattingTag.asTags,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+               HtmlLexerProcessor,
+       ).processTree(this)
+}
+
+fun getRawImageSizeStyleValue(width: Int?, height: Int?) = width?.let { "width:${it * 0.25}px;" }.orEmpty() + height?.let { "height:${it * 0.25}px;" }.orEmpty()
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserTree.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserTree.kt
new file mode 100644 (file)
index 0000000..bd2187c
--- /dev/null
@@ -0,0 +1,204 @@
+package info.mechyrdia.lore
+
+inline fun <TSubject> String.parseAs(converter: ParserTree.() -> TSubject) = ParserState.parseText(this).converter()
+
+inline fun <TContext> String.parseAs(context: TContext, converter: ParserTree.(TContext) -> Unit) = ParserState.parseText(this).converter(context)
+
+sealed class ParserTreeNode {
+       data class Text(val text: String) : ParserTreeNode()
+       
+       data object LineBreak : ParserTreeNode()
+       
+       data class Tag(val tag: String, val param: String?, val subNodes: ParserTree) : ParserTreeNode()
+}
+
+infix fun ParserTreeNode.Tag.isTag(test: String) = tag.equals(test, ignoreCase = true)
+infix fun ParserTreeNode.Tag.isTag(test: Collection<String>) = test.any { tag.equals(it, ignoreCase = true) }
+
+infix fun ParserTreeNode.Tag.isNotTag(test: String) = !tag.equals(test, ignoreCase = true)
+infix fun ParserTreeNode.Tag.isNotTag(test: Collection<String>) = test.none { tag.equals(it, ignoreCase = true) }
+
+typealias ParserTree = List<ParserTreeNode>
+
+sealed class ParserTreeBuilderState {
+       private val nodes = mutableListOf<ParserTreeNode>()
+       private val currentString = StringBuilder()
+       
+       fun text(text: String) {
+               currentString.append(text)
+       }
+       
+       private fun endText() {
+               if (currentString.isEmpty()) return
+               nodes.add(ParserTreeNode.Text(currentString.toString().replace('\n', ' ')))
+               currentString.clear()
+       }
+       
+       fun newLine() {
+               endText()
+               nodes.add(ParserTreeNode.LineBreak)
+       }
+       
+       fun endDoc(): ParserTree {
+               endText()
+               return nodes
+       }
+       
+       fun beginTag(tag: String, param: String?): TreeTag {
+               endText()
+               return TreeTag(this, tag, param)
+       }
+       
+       open fun canEndTag(endTag: String): TreeTag? = null
+       
+       protected fun doneTag(tag: ParserTreeNode.Tag): ParserTreeBuilderState {
+               nodes.add(tag)
+               return this
+       }
+       
+       class TreeRoot : ParserTreeBuilderState()
+       
+       class TreeTag(
+               private val parent: ParserTreeBuilderState,
+               private val tag: String,
+               private val param: String? = null
+       ) : ParserTreeBuilderState() {
+               override fun canEndTag(endTag: String): TreeTag? {
+                       return if (tag.equals(endTag, ignoreCase = true)) this else null
+               }
+               
+               fun endTag(): ParserTreeBuilderState {
+                       return parent.doneTag(ParserTreeNode.Tag(tag, param, endDoc()))
+               }
+       }
+}
+
+sealed class ParserState(
+       protected val builder: ParserTreeBuilderState
+) {
+       abstract fun processCharacter(char: Char): ParserState
+       open fun processEndOfText(): ParserTree = builder.unwind()
+       
+       class Initial : ParserState(ParserTreeBuilderState.TreeRoot()) {
+               override fun processCharacter(char: Char): ParserState {
+                       return if (char == '[')
+                               OpenTag("", builder)
+                       else
+                               PlainText("$char", builder)
+               }
+               
+               override fun processEndOfText(): ParserTree {
+                       return emptyList()
+               }
+       }
+       
+       class PlainText(private val text: String, builder: ParserTreeBuilderState) : ParserState(builder) {
+               override fun processCharacter(char: Char): ParserState {
+                       return if (char == '[') {
+                               builder.text(text)
+                               OpenTag("", builder)
+                       } else if (char == '\n' && text.endsWith('\n')) {
+                               builder.text(text.removeSuffix("\n"))
+                               builder.newLine()
+                               
+                               PlainText("", builder)
+                       } else PlainText("$text$char", builder)
+               }
+               
+               override fun processEndOfText(): ParserTree {
+                       builder.text(text)
+                       return super.processEndOfText()
+               }
+       }
+       
+       class NoFormatText(private val text: String, builder: ParserTreeBuilderState) : ParserState(builder) {
+               override fun processCharacter(char: Char): ParserState {
+                       return if (char == '\n' && text.endsWith('\n')) {
+                               builder.text(text.removeSuffix("\n"))
+                               builder.newLine()
+                               
+                               NoFormatText("", builder)
+                       } else {
+                               val newText = "$text$char"
+                               val endTag = "[/$NO_FORMAT_TAG]"
+                               if (newText.endsWith(endTag, ignoreCase = true)) {
+                                       builder.text(newText.substring(0, newText.length - endTag.length))
+                                       PlainText("", builder)
+                               } else NoFormatText(newText, builder)
+                       }
+               }
+               
+               override fun processEndOfText(): ParserTree {
+                       builder.text(text)
+                       return super.processEndOfText()
+               }
+       }
+       
+       class OpenTag(private val tagName: String, builder: ParserTreeBuilderState) : ParserState(builder) {
+               override fun processCharacter(char: Char): ParserState {
+                       return if (char == ']') {
+                               if (tagName.equals(NO_FORMAT_TAG, ignoreCase = true))
+                                       NoFormatText("", builder)
+                               else
+                                       PlainText("", builder.beginTag(tagName, null))
+                       } else if (char == '=')
+                               TagParam(tagName, "", builder)
+                       else if (char == '/' && tagName.isEmpty())
+                               CloseTag("", builder)
+                       else
+                               OpenTag("$tagName$char", builder)
+               }
+               
+               override fun processEndOfText(): ParserTree {
+                       builder.text("[$tagName")
+                       return super.processEndOfText()
+               }
+       }
+       
+       class TagParam(private val tagName: String, private val tagParam: String, builder: ParserTreeBuilderState) : ParserState(builder) {
+               override fun processCharacter(char: Char): ParserState {
+                       return if (char == ']')
+                               PlainText("", builder.beginTag(tagName, tagParam))
+                       else
+                               TagParam(tagName, "$tagParam$char", builder)
+               }
+               
+               override fun processEndOfText(): ParserTree {
+                       builder.text("[$tagName=$tagParam")
+                       return super.processEndOfText()
+               }
+       }
+       
+       class CloseTag(private val tagName: String, builder: ParserTreeBuilderState) : ParserState(builder) {
+               override fun processCharacter(char: Char): ParserState {
+                       return if (char == ']')
+                               builder.canEndTag(tagName)?.endTag()?.let {
+                                       PlainText("", it)
+                               } ?: PlainText("[/$tagName]", builder)
+                       else CloseTag("$tagName$char", builder)
+               }
+               
+               override fun processEndOfText(): ParserTree {
+                       builder.text("[/$tagName")
+                       return super.processEndOfText()
+               }
+       }
+       
+       companion object {
+               const val NO_FORMAT_TAG = "noformat"
+               
+               private fun ParserTreeBuilderState.unwind(): ParserTree {
+                       return when (this) {
+                               is ParserTreeBuilderState.TreeRoot -> endDoc()
+                               is ParserTreeBuilderState.TreeTag -> endTag().unwind()
+                       }
+               }
+               
+               fun parseText(text: String): ParserTree {
+                       val fixedText = text.replace("\r\n", "\n").replace('\r', '\n')
+                       return fixedText.fold<ParserState>(Initial()) { state, char ->
+                               state.processCharacter(char)
+                       }.processEndOfText()
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserUtils.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserUtils.kt
new file mode 100644 (file)
index 0000000..29742b6
--- /dev/null
@@ -0,0 +1,38 @@
+package info.mechyrdia.lore
+
+fun <T> List<T>.splitOn(predicate: (T) -> Boolean): List<List<T>> {
+       val whole = mutableListOf<List<T>>()
+       val current = mutableListOf<T>()
+       
+       for (item in this) {
+               if (predicate(item)) {
+                       if (current.isNotEmpty()) {
+                               whole.add(current.toList())
+                               current.clear()
+                       }
+               } else
+                       current.add(item)
+       }
+       
+       if (current.isNotEmpty())
+               whole.add(current.toList())
+       
+       return whole.toList()
+}
+
+fun <T> List<T>.splitBefore(predicate: (T) -> Boolean): List<List<T>> {
+       val whole = mutableListOf<List<T>>()
+       val current = mutableListOf<T>()
+       
+       for (item in this) {
+               if (predicate(item)) {
+                       whole.add(current.toList())
+                       current.clear()
+               }
+               
+               current.add(item)
+       }
+       
+       whole.add(current.toList())
+       return whole.toList()
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewBar.kt
new file mode 100644 (file)
index 0000000..06b0d15
--- /dev/null
@@ -0,0 +1,45 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.NationData
+import kotlinx.html.*
+
+abstract class Sidebar {
+       protected abstract fun TagConsumer<*>.display()
+       fun displayIn(aside: ASIDE) = aside.consumer.display()
+}
+
+data class PageNavSidebar(val contents: List<NavItem>) : Sidebar() {
+       override fun TagConsumer<*>.display() {
+               div(classes = "list") {
+                       for (content in contents) {
+                               div(classes = "item") {
+                                       content.displayIn(this)
+                               }
+                       }
+               }
+       }
+}
+
+data class NationProfileSidebar(val nationData: NationData) : Sidebar() {
+       override fun TagConsumer<*>.display() {
+               img(src = nationData.flag, alt = "Flag of ${nationData.name}", classes = "flag-icon")
+               p {
+                       style = "text-align:center"
+                       a(href = "https://www.nationstates.net/nation=${nationData.id}") {
+                               +nationData.name
+                       }
+               }
+       }
+}
+
+data class QuoteOriginSidebar(val author: String, val fullPortrait: String, val fullLink: String) : Sidebar() {
+       override fun TagConsumer<*>.display() {
+               img(src = fullPortrait, alt = "Portrait of $author")
+               p {
+                       style = "text-align:center"
+                       a(href = fullLink) {
+                               +author
+                       }
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewMap.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewMap.kt
new file mode 100644 (file)
index 0000000..141257b
--- /dev/null
@@ -0,0 +1,14 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.StoragePath
+import io.ktor.server.application.*
+
+fun ApplicationCall.galaxyMapPage(): StoragePath {
+       val themeName = when (pageTheme) {
+               PageTheme.SYSTEM -> "system"
+               PageTheme.LIGHT -> "light"
+               PageTheme.DARK -> "dark"
+       }
+       
+       return StoragePath.assetDir / listOf("map", "index-$themeName.html")
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt
new file mode 100644 (file)
index 0000000..44f7890
--- /dev/null
@@ -0,0 +1,112 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.JsonFileCodec
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import info.mechyrdia.data.currentNation
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.createCsrfToken
+import info.mechyrdia.route.href
+import io.ktor.server.application.*
+import kotlinx.html.*
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.ListSerializer
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.collections.set
+
+@Serializable
+private data class ExternalLink(
+       val url: String,
+       val text: String
+)
+
+suspend fun loadExternalLinks(): List<NavItem> {
+       val extraLinksFile = StoragePath.Root / "externalLinks.json"
+       val extraLinksJson = String(FileStorage.instance.readFile(extraLinksFile)!!)
+       val extraLinks = JsonFileCodec.decodeFromString(ListSerializer(ExternalLink.serializer()), extraLinksJson)
+       return if (extraLinks.isEmpty())
+               emptyList()
+       else (listOf(NavHead("See Also")) + extraLinks.map { NavLink(it.url, it.text, textIsHtml = true) })
+}
+
+suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
+       NavLink(href(Root()), "Lore Intro"),
+       NavLink(href(Root.LorePage(emptyList())), TOC_TITLE),
+) + path?.let { pathParts ->
+       pathParts.indices.drop(1).map { i ->
+               val subPath = pathParts.take(i)
+               NavLink(href(Root.LorePage(subPath)), (StoragePath.articleDir / subPath).toFriendlyPageTitle())
+       }
+}.orEmpty() + (currentNation()?.let { data ->
+       listOf(
+               NavHead(data.name),
+               NavLink(href(Root.User()), "Your User Page"),
+               NavLink("https://www.nationstates.net/nation=${data.id}", "Your NationStates Page"),
+               NavLink.withCsrf(href(Root.Auth.LogoutPost()), "Log Out"),
+       )
+} ?: listOf(
+       NavHead("Log In"),
+       NavLink(href(Root.Auth.LoginPage()), "Log In with NationStates"),
+)) + listOf(
+       NavLink(href(Root.ClientPreferences()), "Client Preferences"),
+       NavHead("Useful Links"),
+       NavLink(href(Root.Comments.HelpPage()), "Commenting Help"),
+       NavLink(href(Root.Comments.RecentPage()), "Recent Comments"),
+) + loadExternalLinks() + (if (currentNation()?.id == OwnerNationId)
+       listOf(
+               NavHead("Administration"),
+               NavLink(href(Root.Admin.Vfs.View(emptyList())), "View VFS"),
+       )
+else emptyList())
+
+sealed class NavItem {
+       protected abstract fun DIV.display()
+       fun displayIn(div: DIV) = div.display()
+}
+
+data class NavHead(val label: String) : NavItem() {
+       override fun DIV.display() {
+               span {
+                       style = "font-variant:small-caps;text-decoration:underline"
+                       +label
+               }
+       }
+}
+
+data class NavLink(
+       val to: String,
+       val text: String,
+       val textIsHtml: Boolean = false,
+       val aClasses: String? = null,
+       val linkAttributes: Map<String, String> = emptyMap()
+) : NavItem() {
+       override fun DIV.display() {
+               a(href = to, classes = aClasses) {
+                       for ((attrName, attrValue) in linkAttributes)
+                               attributes[attrName] = attrValue
+                       
+                       if (textIsHtml)
+                               unsafe { raw(text) }
+                       else
+                               +text
+               }
+       }
+       
+       companion object {
+               context(ApplicationCall)
+               fun withCsrf(to: String, text: String, textIsHtml: Boolean = false, aClasses: String? = null, extraAttributes: Map<String, String> = emptyMap): NavLink {
+                       return NavLink(
+                               to = to,
+                               text = text,
+                               textIsHtml = textIsHtml,
+                               aClasses = aClasses,
+                               linkAttributes = extraAttributes + mapOf(
+                                       "data-method" to "post",
+                                       "data-csrf-token" to createCsrfToken(to)
+                               )
+                       )
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewOg.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewOg.kt
new file mode 100644 (file)
index 0000000..94374d7
--- /dev/null
@@ -0,0 +1,31 @@
+package info.mechyrdia.lore
+
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import kotlinx.html.HEAD
+import kotlinx.html.meta
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class OpenGraphData(
+       val desc: String,
+       val image: String
+)
+
+fun HEAD.ogProperty(property: String, content: String) {
+       meta {
+               attributes["property"] = "og:$property"
+               attributes["content"] = content
+       }
+}
+
+context(ApplicationCall)
+fun HEAD.renderOgData(title: String, data: OpenGraphData) {
+       meta(name = "description", content = data.desc)
+       
+       ogProperty("title", title)
+       ogProperty("type", "website")
+       ogProperty("description", data.desc)
+       ogProperty("image", data.image)
+       ogProperty("url", "https://mechyrdia.info/${request.path().removePrefix("/")}")
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt
new file mode 100644 (file)
index 0000000..3618db1
--- /dev/null
@@ -0,0 +1,225 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.route.ErrorMessageAttributeKey
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.util.*
+import kotlinx.html.*
+import java.time.Instant
+
+private val preloadFonts = listOf(
+       "DejaVuSans-Bold.woff",
+       "DejaVuSans-BoldOblique.woff",
+       "DejaVuSans-Oblique.woff",
+       "DejaVuSans.woff",
+       "JetBrainsMono-ExtraBold.woff",
+       "JetBrainsMono-ExtraBoldItalic.woff",
+       "JetBrainsMono-Medium.woff",
+       "JetBrainsMono-MediumItalic.woff",
+       "Oxanium-Bold.woff",
+       "Oxanium-ExtraBold.woff",
+       "Oxanium-Regular.woff",
+       "Oxanium-SemiBold.woff",
+       "tylan-language-alphabet-3.woff",
+       "thedish-language-alphabet.woff",
+       "pokhval-language-alphabet.woff",
+       "kishari-language-alphabet.woff",
+)
+
+private val preloadImages = listOf(
+       "external-link-dark.png",
+       "external-link.png",
+       "icon.png",
+)
+
+context(ApplicationCall)
+private fun HEAD.initialHead(pageTitle: String, ogData: OpenGraphData?) {
+       meta(charset = "utf-8")
+       meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
+       
+       meta(name = "theme-color", content = "#FFCC33")
+       
+       ogData?.let { data ->
+               renderOgData(pageTitle, data)
+       }
+       
+       link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.png")
+       
+       title {
+               +pageTitle
+       }
+}
+
+fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sidebar: Sidebar? = null, ogData: OpenGraphData? = null, content: MAIN.() -> Unit): HTML.() -> Unit {
+       return {
+               pageTheme.attributeValue?.let { attributes["data-theme"] = it }
+               
+               lang = "en"
+               
+               head {
+                       initialHead(pageTitle, ogData)
+                       
+                       for (font in preloadFonts)
+                               link(
+                                       rel = "preload",
+                                       href = "/static/font/$font",
+                                       type = "font/woff"
+                               ) {
+                                       attributes["as"] = "font"
+                               }
+                       
+                       for (image in preloadImages)
+                               link(
+                                       rel = "preload",
+                                       href = "/static/images/$image",
+                                       type = "image/png"
+                               ) {
+                                       attributes["as"] = "image"
+                               }
+                       
+                       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 {
+                       div { id = "bg" }
+                       
+                       navBar?.let { nb ->
+                               nav(classes = "desktop") {
+                                       div(classes = "list") {
+                                               for (ni in nb) {
+                                                       div(classes = "item") {
+                                                               ni.displayIn(this)
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+                       
+                       sidebar?.let {
+                               aside(classes = "desktop") {
+                                       it.displayIn(this)
+                               }
+                       }
+                       
+                       main {
+                               sidebar?.let {
+                                       aside(classes = "mobile") {
+                                               it.displayIn(this)
+                                       }
+                               }
+                               
+                               content()
+                               
+                               navBar?.let { nb ->
+                                       nav(classes = "mobile") {
+                                               div(classes = "list") {
+                                                       for (ni in nb) {
+                                                               div(classes = "item") {
+                                                                       ni.displayIn(this)
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+                       
+                       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 = if (paramsWithoutError.isEmpty())
+                                               ""
+                                       else "?${paramsWithoutError.formUrlEncode()}"
+                                       attributes["data-redirect-url"] = "${request.path()}$newQueryString"
+                                       
+                                       div(classes = "bg")
+                                       div(classes = "msg") {
+                                               p { +errorMessage }
+                                               p { +"Click to close this popup" }
+                                       }
+                               }
+                       }
+               }
+       }
+}
+
+fun ApplicationCall.rawPage(pageTitle: String, ogData: OpenGraphData? = null, content: BODY.() -> Unit): HTML.() -> Unit {
+       return {
+               lang = "en"
+               
+               head {
+                       initialHead(pageTitle, ogData)
+                       
+                       link(rel = "stylesheet", type = "text/css", href = "/static/raw.css")
+               }
+               body {
+                       content()
+               }
+       }
+}
+
+private val adminPreloadFonts = listOf(
+       "JetBrainsMono-ExtraBold.woff",
+       "JetBrainsMono-ExtraBoldItalic.woff",
+       "JetBrainsMono-Medium.woff",
+       "JetBrainsMono-MediumItalic.woff",
+)
+
+fun ApplicationCall.adminPage(pageTitle: String, content: BODY.() -> Unit): HTML.() -> Unit {
+       return {
+               lang = "en"
+               
+               head {
+                       initialHead(pageTitle, null)
+                       
+                       for (font in adminPreloadFonts)
+                               link(
+                                       rel = "preload",
+                                       href = "/static/font/$font",
+                                       type = "font/woff"
+                               ) {
+                                       attributes["as"] = "font"
+                               }
+                       
+                       link(rel = "stylesheet", type = "text/css", href = "/static/admin.css")
+                       
+                       script(src = "/static/admin.js") {}
+               }
+               body {
+                       content()
+               }
+       }
+}
+
+fun FlowOrPhrasingContent.dateTime(instant: Instant) {
+       span(classes = "moment") {
+               style = "display:none"
+               +"${instant.toEpochMilli()}"
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsError.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsError.kt
new file mode 100644 (file)
index 0000000..3d4bded
--- /dev/null
@@ -0,0 +1,76 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.data.currentNation
+import info.mechyrdia.route.CsrfProtectedResourcePayload
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import kotlinx.html.*
+
+suspend fun ApplicationCall.errorPage(title: String, body: FlowContent.() -> Unit): HTML.() -> Unit {
+       return if (request.queryParameters["format"] == "raw")
+               rawPage(title) {
+                       h1 { +title }
+                       body()
+               }
+       else if (request.uri.startsWith("/admin/vfs") && currentNation()?.id == OwnerNationId)
+               adminPage(title) {
+                       div(classes = "message") {
+                               h1 { +title }
+                               body()
+                       }
+               }
+       else
+               page(title, standardNavBar()) {
+                       section {
+                               h1 { +title }
+                               body()
+                       }
+               }
+}
+
+suspend fun ApplicationCall.error400(): HTML.() -> Unit = errorPage("400 Bad Request") {
+       p { +"The request your browser sent was improperly formatted." }
+}
+
+suspend fun ApplicationCall.error403(): HTML.() -> Unit = errorPage("403 Forbidden") {
+       p { +"You are not allowed to do that." }
+}
+
+suspend fun ApplicationCall.error403PageExpired(payload: CsrfProtectedResourcePayload): HTML.() -> Unit = errorPage("Page Expired") {
+       with(payload) { displayRetryData() }
+       p {
+               +"The page you were on has expired."
+               request.header(HttpHeaders.Referrer)?.let { referrer ->
+                       +" You can "
+                       a(href = referrer) { +"return to the previous page" }
+                       +" and retry your action."
+               }
+       }
+}
+
+suspend fun ApplicationCall.error404(): HTML.() -> Unit = errorPage("404 Not Found") {
+       p {
+               +"Unfortunately, we could not find what you were looking for. Would you like to "
+               a(href = href(Root())) { +"return to the index page" }
+               +"?"
+       }
+}
+
+suspend fun ApplicationCall.error409(): HTML.() -> Unit = errorPage("409 Conflict") {
+       p {
+               +"Your attempted action conflicts with an existing resource."
+               request.header(HttpHeaders.Referrer)?.let { referrer ->
+                       +" You can "
+                       a(href = referrer) { +"return to the previous page" }
+                       +" and retry your action."
+               }
+       }
+}
+
+suspend fun ApplicationCall.error500(): HTML.() -> Unit = errorPage("500 Internal Error") {
+       p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsLore.kt
new file mode 100644 (file)
index 0000000..86aea5e
--- /dev/null
@@ -0,0 +1,217 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.JsonFileCodec
+import info.mechyrdia.data.*
+import info.mechyrdia.route.KeyedEnumSerializer
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.href
+import io.ktor.server.application.*
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.toList
+import kotlinx.html.*
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class IntroMetaData(
+       val title: String,
+       val desc: String,
+       val image: String
+) {
+       val ogData: OpenGraphData
+               get() = OpenGraphData(desc, image)
+}
+
+suspend fun ApplicationCall.loreIntroPage(): HTML.() -> Unit {
+       val metaJson = String(FileStorage.instance.readFile(StoragePath.Root / "introMeta.json")!!)
+       val metaData = JsonFileCodec.decodeFromString(IntroMetaData.serializer(), metaJson)
+       
+       val html = String(FileStorage.instance.readFile(StoragePath.Root / "intro.html")!!)
+       
+       return page(metaData.title, standardNavBar(), null, metaData.ogData) {
+               section {
+                       a { id = "page-top" }
+                       unsafe { raw(html) }
+               }
+       }
+}
+
+private val Tag.breadCrumbArrow: Unit
+       get() {
+               +Entities.nbsp
+               +Entities.gt
+               +Entities.nbsp
+       }
+
+context(ApplicationCall)
+private fun FlowContent.breadCrumbs(links: List<Pair<Root.LorePage, String>>) = p {
+       links.joinToHtml(Tag::breadCrumbArrow) { (url, text) ->
+               a(href = href(url)) { +text }
+       }
+}
+
+const val TOC_TITLE = "Table of Contents"
+
+@Serializable(with = LoreArticleFormatSerializer::class)
+enum class LoreArticleFormat(val format: String? = null) {
+       HTML(null),
+       RAW_HTML("raw"),
+       ;
+}
+
+object LoreArticleFormatSerializer : KeyedEnumSerializer<LoreArticleFormat>(LoreArticleFormat.entries, LoreArticleFormat::format)
+
+suspend fun ApplicationCall.loreRawArticlePage(pagePath: List<String>): HTML.() -> Unit {
+       val pageFile = StoragePath.articleDir / pagePath
+       val pageNode = pageFile.toArticleNode()
+       
+       val parentPaths = if (pagePath.isEmpty())
+               emptyList()
+       else {
+               val pathDirs = pagePath.drop(1)
+               listOf(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML) to TOC_TITLE) + pathDirs.indices.map { i ->
+                       val prefixPath = pathDirs.take(i)
+                       Root.LorePage(prefixPath, LoreArticleFormat.RAW_HTML) to (StoragePath.articleDir / prefixPath).toFriendlyPageTitle()
+               }
+       }
+       
+       val isValid = FileStorage.instance.getType(pageFile) != null && pageFile.isViewable
+       
+       if (isValid) {
+               if (pageNode.subNodes != null) {
+                       return rawPage(pageNode.title) {
+                               breadCrumbs(parentPaths)
+                               h1 { +pageNode.title }
+                               ul {
+                                       pageNode.subNodes.renderInto(this, pagePath, LoreArticleFormat.RAW_HTML)
+                               }
+                       }
+               }
+               
+               val pageMarkup = FactbookLoader.loadFactbook(pagePath)
+               if (pageMarkup != null) {
+                       val pageHtml = pageMarkup.toRawHtml()
+                       
+                       val pageToC = TableOfContentsBuilder()
+                       pageMarkup.buildToC(pageToC)
+                       
+                       return rawPage(pageToC.toPageTitle(), pageToC.toOpenGraph()) {
+                               breadCrumbs(parentPaths)
+                               +pageHtml
+                       }
+               }
+       }
+       
+       return rawPage(pageNode.title) {
+               breadCrumbs(parentPaths)
+               h1 { +pageNode.title }
+               p {
+                       +"This factbook does not exist. Would you like to "
+                       a(href = href(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML))) { +"return to the table of contents" }
+                       +"?"
+               }
+       }
+}
+
+suspend fun ApplicationCall.loreArticlePage(pagePath: List<String>, format: LoreArticleFormat = LoreArticleFormat.HTML): HTML.() -> Unit {
+       val totalsData = processGuestbook()
+       
+       if (format == LoreArticleFormat.RAW_HTML)
+               return loreRawArticlePage(pagePath)
+       
+       val pageFile = StoragePath.articleDir / pagePath
+       val pageNode = pageFile.toArticleNode()
+       
+       val (canCommentAs, comments) = coroutineScope {
+               val canCommentAs = async { currentNation() }
+               val comments = async {
+                       CommentRenderData(Comment.getCommentsIn(pagePath).toList(), nationCache)
+               }
+               
+               canCommentAs.await() to comments.await()
+       }
+       
+       val isValid = FileStorage.instance.getType(pageFile) != null && pageFile.isViewable
+       
+       if (isValid) {
+               if (pageNode.subNodes != null) {
+                       val navbar = standardNavBar(pagePath.takeIf { it.isNotEmpty() })
+                       
+                       val sidebar = PageNavSidebar(
+                               listOf(
+                                       NavLink("#page-top", pageNode.title, aClasses = "left"),
+                                       NavLink("#comments", "Comments", aClasses = "left")
+                               )
+                       )
+                       
+                       return page(pageNode.title, navbar, sidebar) {
+                               section {
+                                       a { id = "page-top" }
+                                       h1 { +pageNode.title }
+                                       ul {
+                                               pageNode.subNodes.renderInto(this, pagePath, format = format)
+                                       }
+                               }
+                               
+                               finalSection(pagePath, canCommentAs, comments, totalsData)
+                       }
+               }
+               
+               val pageMarkup = FactbookLoader.loadFactbook(pagePath)
+               if (pageMarkup != null) {
+                       val pageHtml = pageMarkup.toFactbookHtml()
+                       
+                       val pageToC = TableOfContentsBuilder()
+                       pageMarkup.buildToC(pageToC)
+                       
+                       val pageNav = pageToC.toNavBar() + NavLink("#comments", "Comments", aClasses = "left")
+                       
+                       val navbar = standardNavBar(pagePath)
+                       val sidebar = PageNavSidebar(pageNav)
+                       
+                       return page(pageToC.toPageTitle(), navbar, sidebar, pageToC.toOpenGraph()) {
+                               +pageHtml
+                               
+                               finalSection(pagePath, canCommentAs, comments, totalsData)
+                       }
+               }
+       }
+       
+       val title = pageNode.title
+       val navbar = standardNavBar(pagePath)
+       val sidebar = PageNavSidebar(
+               listOf(
+                       NavLink("#page-top", title, aClasses = "left"),
+                       NavLink("#comments", "Comments", aClasses = "left")
+               )
+       )
+       
+       return page(title, navbar, sidebar) {
+               section {
+                       a { id = "page-top" }
+                       h1 { +pageNode.title }
+                       p {
+                               +"This factbook does not exist. Would you like to "
+                               a(href = href(Root())) { +"return to the index page" }
+                               +"?"
+                       }
+               }
+               
+               finalSection(pagePath, canCommentAs, comments, totalsData)
+       }
+}
+
+context(ApplicationCall)
+private fun SectioningOrFlowContent.finalSection(pagePathParts: List<String>, canCommentAs: NationData?, comments: List<CommentRenderData>, totalsData: PageVisitTotals) {
+       section {
+               h2 {
+                       a { id = "comments" }
+                       +"Comments"
+               }
+               commentInput(pagePathParts, canCommentAs)
+               for (comment in comments)
+                       commentBox(comment, canCommentAs?.id)
+               
+               guestbook(totalsData)
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt
new file mode 100644 (file)
index 0000000..99307e9
--- /dev/null
@@ -0,0 +1,123 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.auth.PageDoNotCacheAttributeKey
+import info.mechyrdia.route.KeyedEnumSerializer
+import io.ktor.server.application.*
+import kotlinx.html.*
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+
+@Serializable(PageThemeSerializer::class)
+enum class PageTheme(val attributeValue: String?) {
+       SYSTEM(null),
+       LIGHT("light"),
+       DARK("dark");
+}
+
+object PageThemeSerializer : KeyedEnumSerializer<PageTheme>(PageTheme.entries, PageTheme::attributeValue)
+
+val ApplicationCall.pageTheme: PageTheme
+       get() = when (request.cookies["FACTBOOK_THEME"]) {
+               "light" -> PageTheme.LIGHT
+               "dark" -> PageTheme.DARK
+               else -> PageTheme.SYSTEM
+       }
+
+@Serializable(with = April1stModeSerializer::class)
+enum class April1stMode {
+       DEFAULT {
+               override val isEnabled: Boolean
+                       get() = isApril1st()
+       },
+       ALWAYS {
+               override val isEnabled: Boolean
+                       get() = true
+       },
+       NEVER {
+               override val isEnabled: Boolean
+                       get() = false
+       };
+       
+       abstract val isEnabled: Boolean
+}
+
+object April1stModeSerializer : KeyedEnumSerializer<April1stMode>(April1stMode.entries)
+
+val ApplicationCall.april1stMode: April1stMode
+       get() = when (request.cookies["APRIL_1ST_MODE"]) {
+               "always" -> April1stMode.ALWAYS
+               "never" -> April1stMode.NEVER
+               else -> April1stMode.DEFAULT
+       }
+
+class JoinToHtmlConsumer<E>(val iterator: Iterator<E>) {
+       inline fun <T : Tag> T.invokeReceiver(separator: T.() -> Unit, body: T.(E) -> Unit) {
+               var isFirst = true
+               for (item in iterator) {
+                       if (isFirst)
+                               isFirst = false
+                       else
+                               separator()
+                       body(item)
+               }
+       }
+       
+       context(T)
+       inline operator fun <T : Tag> invoke(separator: T.() -> Unit, body: T.(E) -> Unit) {
+               invokeReceiver(separator, body)
+       }
+}
+
+val <E> Iterable<E>.joinToHtml: JoinToHtmlConsumer<E>
+       get() = JoinToHtmlConsumer(iterator())
+
+inline fun <reified E : Enum<E>> FlowOrInteractiveOrPhrasingContent.preference(inputName: String, current: E, crossinline localize: (E) -> String) {
+       val serializer = serializer<E>() as? KeyedEnumSerializer<E> ?: throw UnsupportedOperationException("Serializer for ${E::class.simpleName} has not been declared as KeyedEnumSerializer")
+       val entries = serializer.entries
+       
+       entries.joinToHtml(Tag::br) { option ->
+               label {
+                       radioInput(name = inputName) {
+                               value = serializer.getKey(option) ?: "null"
+                               required = true
+                               checked = current == option
+                       }
+                       +Entities.nbsp
+                       +localize(option)
+               }
+       }
+}
+
+suspend fun ApplicationCall.clientSettingsPage(): HTML.() -> Unit {
+       attributes.put(PageDoNotCacheAttributeKey, true)
+       
+       val theme = pageTheme
+       val april1st = april1stMode
+       
+       return page("Client Preferences", standardNavBar()) {
+               section {
+                       h1 { +"Client Preferences" }
+                       p { +"This is the place where you can adjust your client preferences. Selecting an option changes it automatically, so you don't need to click any kind of \"save\" button. Also, note that preferences are saved per-browser in your cookies, so don't expect your client-side preferences to travel with you to other devices." }
+               }
+               section {
+                       h2 { +"Page Theme" }
+                       preference<PageTheme>("theme", theme) {
+                               when (it) {
+                                       PageTheme.SYSTEM -> "Chosen by Browser/System"
+                                       PageTheme.LIGHT -> "Light Theme"
+                                       PageTheme.DARK -> "Dark Theme"
+                               }
+                       }
+               }
+               section {
+                       h2 { +"April Fools' Day Mode" }
+                       preference<April1stMode>("april1st", april1st) {
+                               when (it) {
+                                       April1stMode.DEFAULT -> "Only on April 1st"
+                                       April1stMode.ALWAYS -> "Always"
+                                       April1stMode.NEVER -> "Never"
+                               }
+                       }
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt
new file mode 100644 (file)
index 0000000..cffd232
--- /dev/null
@@ -0,0 +1,136 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.JsonFileCodec
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+import info.mechyrdia.route.KeyedEnumSerializer
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.html.*
+import io.ktor.server.response.*
+import io.ktor.util.*
+import kotlinx.html.*
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+@Serializable
+data class Quote(
+       val quote: String,
+       val author: String,
+       val portrait: String,
+       val link: String
+) {
+       val fullPortrait: String
+               get() = if (portrait.startsWith("http://") || portrait.startsWith("https://"))
+                       portrait
+               else
+                       "https://mechyrdia.info/assets/images/$portrait"
+       
+       val fullLink: String
+               get() = if (link.startsWith("http://") || link.startsWith("https://"))
+                       link
+               else
+                       "https://mechyrdia.info/lore/$link"
+}
+
+private val quotesListGetter by storedData(StoragePath("quotes.json")) { jsonPath ->
+       FileStorage.instance.readFile(jsonPath)?.let {
+               JsonFileCodec.decodeFromString(ListSerializer(Quote.serializer()), String(it))
+       }
+}
+
+suspend fun getQuotesList() = quotesListGetter()!!
+
+suspend fun randomQuote(): Quote = getQuotesList().random()
+
+@Serializable(with = QuoteFormatSerializer::class)
+enum class QuoteFormat(val format: String?) {
+       HTML(null) {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondHtml(HttpStatusCode.OK, block = quote.toHtml(RANDOM_QUOTE_HTML_TITLE))
+               }
+       },
+       RAW_HTML("raw") {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondHtml(HttpStatusCode.OK, block = quote.toRawHtml(RANDOM_QUOTE_HTML_TITLE))
+               }
+       },
+       JSON("json") {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondText(quote.toJson(), contentType = ContentType.Application.Json)
+               }
+       },
+       XML("xml") {
+               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
+                       respondText(quote.toXml(), contentType = ContentType.Application.Xml)
+               }
+       },
+       ;
+       
+       abstract suspend fun ApplicationCall.respondQuote(quote: Quote)
+}
+
+object QuoteFormatSerializer : KeyedEnumSerializer<QuoteFormat>(QuoteFormat.entries, QuoteFormat::format)
+
+const val RANDOM_QUOTE_HTML_TITLE = "Random Quote"
+
+fun Quote.toXml(standalone: Boolean = true): String {
+       return buildString {
+               if (standalone)
+                       appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
+               appendLine("<quote>")
+               append("<text>").append(quote.escapeHTML()).appendLine("</text>")
+               append("<author>").append(author.escapeHTML()).appendLine("</author>")
+               append("<portrait href=\"").append(fullPortrait.escapeHTML()).appendLine("\" />")
+               append("<link href=\"").append(fullLink.escapeHTML()).appendLine("\" />")
+               appendLine("</quote>")
+       }
+}
+
+fun Quote.toJson(): String {
+       return buildJsonObject {
+               put("text", quote)
+               put("author", author)
+               put("portrait", fullPortrait)
+               put("link", fullLink)
+       }.toString()
+}
+
+context(Quote)
+private fun FlowContent.quoteWithAttribution(pageTitle: String) {
+       h1 { +pageTitle }
+       blockQuote {
+               +quote
+       }
+       p {
+               style = "align:right"
+               unsafe { raw("&#x2015;") }
+               +Entities.nbsp
+               a(href = fullLink) { +author }
+       }
+}
+
+context(ApplicationCall)
+suspend fun Quote.toHtml(title: String): HTML.() -> Unit {
+       return page(title, standardNavBar(), QuoteOriginSidebar(author, fullPortrait, fullLink)) {
+               section {
+                       a { id = "page-top" }
+                       quoteWithAttribution(title)
+               }
+       }
+}
+
+context(ApplicationCall)
+fun Quote.toRawHtml(title: String): HTML.() -> Unit {
+       return rawPage(title) {
+               quoteWithAttribution(title)
+               p {
+                       style = "align:center"
+                       a(href = fullLink) {
+                               img(src = fullPortrait, alt = "Image of $author")
+                       }
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt
new file mode 100644 (file)
index 0000000..a95aa07
--- /dev/null
@@ -0,0 +1,46 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+
+private const val AVERAGE_FACTBOOK_INTRO_CHANGEFREQ = "daily"
+private const val AVERAGE_FACTBOOK_PAGE_CHANGEFREQ = "hourly"
+private const val FACTBOOK_INTRO_PRIORITY = "0.7"
+private const val FACTBOOK_PAGE_PRIORITY = "0.8"
+
+private suspend fun Appendable.renderLoreSitemap() {
+       for (page in allPages()) {
+               if (page.path.isViewable) {
+                       val lastModified = page.stat.updated
+                       
+                       appendLine("\t<url>")
+                       appendLine("\t\t<loc>https://mechyrdia.info/lore/${page.path}</loc>")
+                       appendLine("\t\t<lastmod>$lastModified</lastmod>")
+                       appendLine("\t\t<changefreq>$AVERAGE_FACTBOOK_PAGE_CHANGEFREQ</changefreq>")
+                       appendLine("\t\t<priority>$FACTBOOK_PAGE_PRIORITY</priority>")
+                       appendLine("\t</url>")
+               }
+       }
+}
+
+private suspend fun Appendable.renderIntroSitemap() {
+       val introFile = FileStorage.instance.statFile(StoragePath.Root / "intro.html") ?: return
+       val introMetaFile = FileStorage.instance.statFile(StoragePath.Root / "introMeta.json") ?: return
+       
+       val introLastModified = maxOf(introFile.updated, introMetaFile.updated)
+       
+       appendLine("\t<url>")
+       appendLine("\t\t<loc>https://mechyrdia.info/</loc>")
+       appendLine("\t\t<lastmod>$introLastModified</lastmod>")
+       appendLine("\t\t<changefreq>$AVERAGE_FACTBOOK_INTRO_CHANGEFREQ</changefreq>")
+       appendLine("\t\t<priority>$FACTBOOK_INTRO_PRIORITY</priority>")
+       appendLine("\t</url>")
+}
+
+suspend fun Appendable.generateSitemap() {
+       appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
+       appendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">")
+       renderIntroSitemap()
+       renderLoreSitemap()
+       appendLine("</urlset>")
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt
new file mode 100644 (file)
index 0000000..c89d975
--- /dev/null
@@ -0,0 +1,291 @@
+package info.mechyrdia.lore
+
+import com.mongodb.client.model.Sorts
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.data.*
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.util.*
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.filterNot
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+data class StoragePathWithStat(val path: StoragePath, val stat: StoredFileStats)
+
+private suspend fun ArticleNode.getPages(base: StoragePath): List<StoragePathWithStat> {
+       if (!this.isViewable)
+               return emptyList()
+       val path = base / name
+       val stat = FileStorage.instance.statFile(path)
+       return if (stat != null)
+               listOf(StoragePathWithStat(path, stat))
+       else if (subNodes != null) coroutineScope {
+               subNodes.map { subNode ->
+                       async {
+                               subNode.getPages(path)
+                       }
+               }.awaitAll().flatten()
+       } else emptyList()
+}
+
+suspend fun allPages(): List<StoragePathWithStat> {
+       return coroutineScope {
+               rootArticleNodeList().map { subNode ->
+                       async {
+                               subNode.getPages(StoragePath.articleDir)
+                       }
+               }.awaitAll().flatten()
+       }
+}
+
+suspend fun Appendable.generateRecentPageEdits() {
+       val pages = allPages().sortedByDescending { it.stat.updated }
+       
+       val mostRecentChange = pages.firstOrNull()?.stat?.updated
+       
+       RssChannel(
+               title = "Recently Edited Factbooks | The Hour of Decision",
+               link = "https://mechyrdia.info",
+               description = "An RSS feed containing all factbooks in The Hour of Decision, in order of most recently edited.",
+               pubDate = mostRecentChange,
+               lastBuildDate = mostRecentChange,
+               ttl = 30,
+               categories = listOf(
+                       RssCategory(domain = "https://nationstates.net", category = "Mechyrdia")
+               ),
+               items = coroutineScope {
+                       pages.map { page ->
+                               async {
+                                       val pageLink = page.path.elements.drop(1)
+                                       val pageMarkup = FactbookLoader.loadFactbook(pageLink) ?: return@async null
+                                       
+                                       val pageToC = TableOfContentsBuilder()
+                                       pageMarkup.buildToC(pageToC)
+                                       val pageOg = pageToC.toOpenGraph()
+                                       
+                                       val imageEnclosure = pageOg?.image?.let { url ->
+                                               val assetPath = url.removePrefix("https://mechyrdia.info/assets/")
+                                               val file = StoragePath.assetDir / assetPath
+                                               RssItemEnclosure(
+                                                       url = url,
+                                                       length = FileStorage.instance.statFile(file)?.size ?: 0L,
+                                                       type = ContentType.defaultForFileExtension(assetPath.substringAfterLast('.')).toString()
+                                               )
+                                       }
+                                       
+                                       RssItem(
+                                               title = pageToC.toPageTitle(),
+                                               description = pageOg?.desc,
+                                               link = "https://mechyrdia.info/lore${pageLink.joinToString { "/$it" }}",
+                                               author = null,
+                                               comments = "https://mechyrdia.info/lore${pageLink.joinToString { "/$it" }}#comments",
+                                               enclosure = imageEnclosure,
+                                               pubDate = page.stat.updated
+                                       )
+                               }
+                       }.awaitAll().filterNotNull()
+               }
+       ).toXml(this)
+}
+
+suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): Appendable.() -> Unit {
+       val currNation = currentNation()
+       
+       val validLimits = 1..100
+       
+       if (limit !in validLimits)
+               return RssChannel(
+                       title = "Recent Comments - Error | The Hour of Decision",
+                       link = "https://mechyrdia.info/comment/recent",
+                       description = "Comment limit must be between ${validLimits.first} and ${validLimits.last}, got $limit",
+                       pubDate = null,
+                       lastBuildDate = Instant.now(),
+                       ttl = 120,
+               )::toXml
+       
+       val comments = CommentRenderData(
+               Comment.Table
+                       .sorted(Sorts.descending(Comment::submittedAt.serialName))
+                       .let { flow ->
+                               if (currNation?.id == OwnerNationId)
+                                       flow
+                               else flow.filterNot { comment ->
+                                       comment.submittedBy != currNation?.id && NationData.get(comment.submittedBy).isBanned
+                               }
+                       }
+                       .take(limit)
+                       .toList(),
+               nationCache
+       )
+       
+       val mostRecentComment = comments.firstOrNull()?.let { it.lastEdit ?: it.submittedAt }
+       
+       return RssChannel(
+               title = "Recent Comments | The Hour of Decision",
+               link = "https://mechyrdia.info/comment/recent",
+               description = "An RSS feed containing the $limit most recently-submitted comments",
+               pubDate = mostRecentComment,
+               lastBuildDate = mostRecentComment,
+               ttl = 60,
+               categories = listOf(
+                       RssCategory(domain = "https://nationstates.net", category = "Mechyrdia")
+               ),
+               items = comments.map { comment ->
+                       RssItem(
+                               title = "Comment by ${comment.submittedBy.name} on https://mechyrdia.info/lore/${comment.submittedIn}",
+                               description = comment.contentsRaw.parseAs(ParserTree::toCommentPlainText),
+                               link = "https://mechyrdia.info/comment/view/${comment.id}",
+                               author = null,
+                               comments = "https://mechyrdia.info/lore/${comment.submittedIn}#comment-${comment.id}",
+                               pubDate = comment.lastEdit ?: comment.submittedAt,
+                               categories = listOf(
+                                       RssCategory(domain = "https://nationstates.net", category = comment.submittedBy.name)
+                               )
+                       )
+               }
+       )::toXml
+}
+
+data class RssCategory(
+       val category: String,
+       val domain: String? = null
+) {
+       fun toXml(appendable: Appendable) {
+               appendable.append("<category")
+               if (domain == null)
+                       appendable.append(">")
+               else
+                       appendable.append(" domain=\"").append(domain.escapeHTML()).append("\">")
+               appendable.append(category.escapeHTML()).appendLine("</category>")
+       }
+}
+
+data class RssChannelImage(
+       val url: String,
+       val title: String,
+       val link: String,
+) {
+       fun toXml(appendable: Appendable) {
+               appendable.appendLine("<image>")
+               appendable.append("<url>").append(url.escapeHTML()).appendLine("</url>")
+               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
+               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
+               appendable.appendLine("</image>")
+       }
+}
+
+const val DEFAULT_RSS_COPYRIGHT = "Copyright 2022 Lanius Trolling"
+const val DEFAULT_RSS_EMAIL = "lanius@laniustrolling.dev (Lanius Trolling)"
+
+val RssDateFormat: DateTimeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME
+fun Instant.toXml() = RssDateFormat.format(atOffset(ZoneOffset.UTC)).escapeHTML()
+
+data class RssChannel(
+       val title: String,
+       val link: String,
+       val description: String,
+       val language: String? = "en-us",
+       val copyright: String? = DEFAULT_RSS_COPYRIGHT,
+       val managingEditor: String? = DEFAULT_RSS_EMAIL,
+       val webMaster: String? = managingEditor,
+       val pubDate: Instant? = null,
+       val lastBuildDate: Instant? = null,
+       val ttl: Int? = null,
+       val image: RssChannelImage? = null,
+       val categories: List<RssCategory> = emptyList(),
+       val items: List<RssItem> = emptyList(),
+) {
+       fun toXml(appendable: Appendable) {
+               appendable.appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
+               appendable.appendLine("<rss version=\"2.0\">")
+               appendable.appendLine("<channel>")
+               
+               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
+               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
+               appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
+               
+               if (language != null)
+                       appendable.append("<language>").append(language.escapeHTML()).appendLine("</language>")
+               if (copyright != null)
+                       appendable.append("<copyright>").append(copyright.escapeHTML()).appendLine("</copyright>")
+               if (managingEditor != null)
+                       appendable.append("<managingEditor>").append(managingEditor.escapeHTML()).appendLine("</managingEditor>")
+               if (webMaster != null)
+                       appendable.append("<webMaster>").append(webMaster.escapeHTML()).appendLine("</webMaster>")
+               if (pubDate != null)
+                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
+               if (lastBuildDate != null)
+                       appendable.append("<lastBuildDate>").append(lastBuildDate.toXml()).appendLine("</lastBuildDate>")
+               if (ttl != null)
+                       appendable.append("<ttl>").append(ttl.toString()).appendLine("</ttl>")
+               
+               image?.toXml(appendable)
+               
+               for (category in categories)
+                       category.toXml(appendable)
+               for (item in items)
+                       item.toXml(appendable)
+               
+               appendable.appendLine("</channel>")
+               appendable.appendLine("</rss>")
+       }
+}
+
+data class RssItemEnclosure(
+       val url: String,
+       val length: Long,
+       val type: String,
+) {
+       fun toXml(appendable: Appendable) {
+               appendable.append("<enclosure ")
+                       .append("url=\"").append(url.escapeHTML()).append("\" ")
+                       .append("length=\"").append(length.toString()).append("\" ")
+                       .append("type=\"").append(type.escapeHTML()).append("\" ")
+                       .appendLine("/>")
+       }
+}
+
+data class RssItem(
+       val title: String? = null,
+       val description: String? = null,
+       val link: String? = null,
+       val author: String? = DEFAULT_RSS_EMAIL,
+       val comments: String? = null,
+       val enclosure: RssItemEnclosure? = null,
+       val pubDate: Instant? = null,
+       val categories: List<RssCategory> = emptyList(),
+) {
+       init {
+               require(title != null || description != null) { "Either title or description must be provided, got null for both" }
+       }
+       
+       fun toXml(appendable: Appendable) {
+               appendable.appendLine("<item>")
+               
+               if (title != null)
+                       appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
+               if (description != null)
+                       appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
+               if (link != null)
+                       appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
+               if (author != null)
+                       appendable.append("<author>").append(author.escapeHTML()).appendLine("</author>")
+               if (comments != null)
+                       appendable.append("<comments>").append(comments.escapeHTML()).appendLine("</comments>")
+               enclosure?.toXml(appendable)
+               
+               if (pubDate != null)
+                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
+               
+               for (category in categories)
+                       category.toXml(appendable)
+               
+               appendable.appendLine("</item>")
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/april_1st.kt
deleted file mode 100644 (file)
index d2db934..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import io.ktor.server.application.*
-import java.time.Instant
-import java.time.Month
-import java.time.ZoneId
-
-private val myTimeZone: ZoneId = ZoneId.of("America/New_York")
-
-fun isApril1st(time: Instant = Instant.now()): Boolean {
-       val zonedDateTime = time.atZone(myTimeZone)
-       return zonedDateTime.month == Month.APRIL && zonedDateTime.dayOfMonth == 1
-}
-
-context(ApplicationCall)
-suspend fun redirectFileOnApril1st(requestedFile: StoragePath): StoragePath? {
-       if (!april1stMode.isEnabled) return null
-       
-       val path = StoragePath.april1Dir / requestedFile.elements
-       if (FileStorage.instance.statFile(path) == null) return null
-       return path
-}
-
-context(ApplicationCall)
-suspend fun getAssetFile(requestedFile: StoragePath): StoragePath {
-       return redirectFileOnApril1st(requestedFile) ?: requestedFile
-}
-
-suspend fun ApplicationCall.respondAsset(assetFile: StoragePath) {
-       respondCompressedFile(getAssetFile(assetFile))
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt
deleted file mode 100644 (file)
index 9a90123..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.Configuration
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.href
-import io.ktor.server.application.*
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
-import kotlinx.html.UL
-import kotlinx.html.a
-import kotlinx.html.li
-import kotlinx.html.ul
-
-data class ArticleNode(val name: String, val title: String, val subNodes: List<ArticleNode>)
-
-suspend fun rootArticleNodeList(): List<ArticleNode> = StoragePath.articleDir.toArticleNode().subNodes
-
-suspend fun StoragePath.toArticleNode(): ArticleNode = ArticleNode(
-       name,
-       toFriendlyPageTitle(),
-       coroutineScope {
-               val path = this@toArticleNode
-               FileStorage.instance.listDir(path)?.map {
-                       val subPath = path / it.name
-                       async { subPath.toArticleNode() }
-               }?.awaitAll().orEmpty()
-       }.sortedBy { it.name }.sortedBy { it.subNodes.isEmpty() }
-)
-
-private val String.isViewable: Boolean
-       get() = Configuration.Current.isDevMode || !(endsWith(".wip") || endsWith(".old"))
-
-val ArticleNode.isViewable: Boolean
-       get() = name.isViewable
-
-val StoragePath.isViewable: Boolean
-       get() = name.isViewable
-
-context(ApplicationCall)
-fun List<ArticleNode>.renderInto(list: UL, base: List<String> = emptyList(), format: LoreArticleFormat = LoreArticleFormat.HTML) {
-       for (node in this) {
-               if (node.isViewable)
-                       list.li {
-                               val nodePath = base + node.name
-                               a(href = href(Root.LorePage(nodePath, format))) { +node.title }
-                               if (node.subNodes.isNotEmpty())
-                                       ul {
-                                               node.subNodes.renderInto(this, nodePath, format)
-                                       }
-                       }
-       }
-}
-
-suspend fun StoragePath.toFriendlyPageTitle() = ArticleTitleCache.get(this)
-       ?: if (elements.size > 1)
-               elements.lastOrNull()?.split('-')?.joinToString(separator = " ") { word ->
-                       word.lowercase().replaceFirstChar { it.titlecase() }
-               }.orEmpty()
-       else TOC_TITLE
-
-suspend fun StoragePath.toFriendlyPathTitle(): String {
-       val lorePath = elements.drop(1)
-       if (lorePath.isEmpty()) return TOC_TITLE
-       
-       return lorePath.indices.drop(1).map { index ->
-               StoragePath(lorePath.take(index)).toFriendlyPageTitle()
-       }.joinToString(separator = " - ")
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/article_titles.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/article_titles.kt
deleted file mode 100644 (file)
index 1bbf15b..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-
-object ArticleTitleCache : FileDependentCache<String>() {
-       override suspend fun processFile(path: StoragePath): String? {
-               if (path.elements[0] != StoragePath.articleDir.elements[0])
-                       return null
-               
-               val bytes = FileStorage.instance.readFile(path) ?: return null
-               val text = String(bytes)
-               
-               return text
-                       .lineSequence()
-                       .first()
-                       .removePrefix("[h1]")
-                       .removeSuffix("[/h1]")
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_caching.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_caching.kt
deleted file mode 100644 (file)
index 4db2e64..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import io.ktor.util.*
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import java.time.Instant
-import java.util.concurrent.ConcurrentHashMap
-
-val StoragePathAttributeKey = AttributeKey<StoragePath>("Mechyrdia.StoragePath")
-
-abstract class FileDependentCache<T : Any> {
-       private inner class Entry(updated: Instant?, data: T?) {
-               private var updated: Instant = updated ?: Instant.MIN
-               var data: T? = data
-                       private set
-               
-               private val updateLock = Mutex()
-               
-               private fun clear() {
-                       updated = Instant.MIN
-                       data = null
-               }
-               
-               suspend fun updateIfNeeded(path: StoragePath): Entry {
-                       return updateLock.withLock {
-                               FileStorage.instance.statFile(path)?.updated?.let { fileUpdated ->
-                                       if (updated < fileUpdated) {
-                                               updated = fileUpdated
-                                               data = processFile(path)
-                                       }
-                                       this
-                               } ?: apply { clear() }
-                       }
-               }
-       }
-       
-       private val cacheLock = Mutex()
-       private val cache = ConcurrentHashMap<StoragePath, Entry>()
-       
-       private suspend fun Entry(path: StoragePath) = cacheLock.withLock {
-               cache.computeIfAbsent(path) {
-                       Entry(null, null)
-               }
-       }
-       
-       protected abstract suspend fun processFile(path: StoragePath): T?
-       
-       suspend fun get(path: StoragePath): T? {
-               return Entry(path).updateIfNeeded(path).data
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_compression.kt
deleted file mode 100644 (file)
index 030ce60..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import info.mechyrdia.data.respondStoredFile
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import java.io.ByteArrayOutputStream
-import java.io.FilterOutputStream
-import java.io.OutputStream
-import java.util.zip.DeflaterOutputStream
-import java.util.zip.GZIPOutputStream
-
-private val gzippedCache = CompressedCache("gzip", ::GZIPOutputStream)
-private val deflatedCache = CompressedCache("deflate", ::DeflaterOutputStream)
-
-private fun getCacheByEncoding(encoding: String) = when (encoding) {
-       "gzip" -> gzippedCache
-       "deflate" -> deflatedCache
-       else -> null
-}
-
-private fun ApplicationCall.compressedCache(): CompressedCache? {
-       return request.acceptEncodingItems()
-               .mapNotNull { item -> getCacheByEncoding(item.value)?.let { it to item.quality } }
-               .maxByOrNull { it.second }
-               ?.first
-}
-
-suspend fun ApplicationCall.respondCompressedFile(path: StoragePath) {
-       val cache = compressedCache() ?: return respondStoredFile(path)
-       val compressedBytes = cache.get(path) ?: return respond(HttpStatusCode.NotFound)
-       attributes.put(StoragePathAttributeKey, path)
-       response.header(HttpHeaders.ContentEncoding, cache.encoding)
-       respondBytes(compressedBytes)
-}
-
-private class CompressedCache(val encoding: String, private val compressorFactory: (OutputStream, Boolean) -> FilterOutputStream) : FileDependentCache<ByteArray>() {
-       override suspend fun processFile(path: StoragePath): ByteArray? {
-               val fileContents = FileStorage.instance.readFile(path) ?: return null
-               
-               return withContext(Dispatchers.IO) {
-                       ByteArrayOutputStream().also { oStream ->
-                               compressorFactory(oStream, true).use { gzip ->
-                                       gzip.write(fileContents)
-                                       gzip.flush()
-                               }
-                       }.toByteArray()
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/asset_hashing.kt
deleted file mode 100644 (file)
index eab3a87..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import io.ktor.http.content.*
-import io.ktor.server.application.*
-import io.ktor.server.http.content.*
-import kotlinx.coroutines.*
-import java.io.IOException
-import java.io.OutputStream
-import java.security.MessageDigest
-import java.util.*
-
-private class DigestingOutputStream(stomach: MessageDigest) : OutputStream() {
-       private var stomachStore: MessageDigest? = stomach
-       
-       private val stomach: MessageDigest
-               get() = stomachStore ?: throw IOException("Attempt to use DigestingOutputStream after it has been closed")
-       
-       val isWritable: Boolean
-               get() = stomachStore != null
-       
-       private var resultStore: ByteArray? = null
-       
-       val result: ByteArray
-               get() = resultStore ?: throw IOException("Attempt to retrieve result of DigestingOutputStream before it has finished")
-       
-       val isDone: Boolean
-               get() = resultStore != null
-       
-       override fun write(b: Int) {
-               stomach.update(b.toByte())
-       }
-       
-       override fun write(b: ByteArray) {
-               stomach.update(b)
-       }
-       
-       override fun write(b: ByteArray, off: Int, len: Int) {
-               stomach.update(b, off, len)
-       }
-       
-       override fun close() {
-               resultStore = stomach.digest()
-               stomachStore = null
-       }
-       
-       inline fun useAndGet(block: (DigestingOutputStream) -> Unit): ByteArray {
-               use(block)
-               return result
-       }
-}
-
-private class FileHashCache(val hashAlgo: String) : FileDependentCache<ByteArray>() {
-       private val hashinator: ThreadLocal<MessageDigest> = ThreadLocal.withInitial { MessageDigest.getInstance(hashAlgo) }
-       
-       override suspend fun processFile(path: StoragePath): ByteArray? {
-               val fileContents = FileStorage.instance.readFile(path) ?: return null
-               
-               return withContext(Dispatchers.IO) {
-                       DigestingOutputStream(hashinator.get()).useAndGet { oStream ->
-                               oStream.write(fileContents)
-                       }
-               }
-       }
-}
-
-private val eTagCache = FileHashCache("SHA-384")
-
-private val b64Encoder: Base64.Encoder = Base64.getUrlEncoder()
-
-suspend fun StoragePath.eTag(): String? = eTagCache.get(this)?.let(b64Encoder::encodeToString)
-
-private suspend fun StoragePath.getVersionHeaders() = coroutineScope {
-       listOf(
-               async {
-                       eTag()?.let {
-                               EntityTagVersion(it)
-                       }
-               },
-               async {
-                       FileStorage.instance.statFile(this@getVersionHeaders)?.updated?.toEpochMilli()?.let {
-                               LastModifiedVersion(it)
-                       }
-               }
-       ).awaitAll().filterNotNull()
-}
-
-suspend fun getVersionHeaders(call: ApplicationCall): List<Version> {
-       return call.attributes.getOrNull(StoragePathAttributeKey)?.getVersionHeaders().orEmpty()
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/file_data.kt
deleted file mode 100644 (file)
index 42a887d..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import java.time.Instant
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-
-fun <T : Any> storedData(path: StoragePath, loader: suspend (StoragePath) -> T?): ReadOnlyProperty<Any?, suspend () -> T?> = object : ReadOnlyProperty<Any?, suspend () -> T?> {
-       private var loadedValue: T? = null
-       private var lastChanged = Instant.MIN
-       
-       private val lock = Mutex()
-       
-       override fun getValue(thisRef: Any?, property: KProperty<*>): suspend () -> T? {
-               return suspend {
-                       lock.withLock {
-                               val cached = loadedValue
-                               val lastMod = FileStorage.instance.statFile(path)?.updated ?: return@withLock null
-                               
-                               if (lastChanged < lastMod) {
-                                       lastChanged = lastMod
-                                       loader(path).also {
-                                               loadedValue = it
-                                       }
-                               } else cached
-                       }
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/http_utils.kt
deleted file mode 100644 (file)
index 566dba7..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.route.href
-import io.ktor.server.application.*
-
-data class HttpRedirectException(val url: String, val permanent: Boolean) : RuntimeException()
-
-fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent)
-
-inline fun <reified T : Any> ApplicationCall.redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_builder.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_builder.kt
deleted file mode 100644 (file)
index f5b90d4..0000000
+++ /dev/null
@@ -1,169 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.Comment
-import info.mechyrdia.data.Id
-
-abstract class BuilderLexerProcessor<TContext> : LexerTagFallback<TContext, Unit>, LexerTextProcessor<TContext, Unit>, LexerLineBreakProcessor<TContext, Unit>, LexerCombiner<TContext, Unit> {
-       override fun processInvalidTag(env: LexerTagEnvironment<TContext, Unit>, tag: String, param: String?, subNodes: ParserTree) {
-               // no-op
-       }
-       
-       override fun processText(env: LexerTagEnvironment<TContext, Unit>, text: String) {
-               // no-op
-       }
-       
-       override fun processLineBreak(env: LexerTagEnvironment<TContext, Unit>) {
-               // no-op
-       }
-       
-       override fun combine(env: LexerTagEnvironment<TContext, Unit>, subjects: List<Unit>) {
-               // no-op
-       }
-}
-
-typealias BuilderTag<TContext> = LexerTagProcessor<TContext, Unit>
-
-object ToCBuilderProcessor : BuilderLexerProcessor<TableOfContentsBuilder>()
-
-class TableOfContentsBuilder {
-       private var title: String? = null
-       private val levels = mutableListOf<Int>()
-       private val links = mutableListOf<NavLink>()
-       
-       fun addHeader(text: String, level: Int, toAnchor: String) {
-               if (level == 0) {
-                       if (title == null)
-                               title = text
-                       
-                       return
-               }
-               
-               if (level > levels.size)
-                       levels.add(1)
-               else {
-                       val newLevels = levels.take(level).mapIndexed { i, n -> if (i == level - 1) n + 1 else n }
-                       levels.clear()
-                       levels.addAll(newLevels)
-               }
-               
-               val number = levels.joinToString(separator = ".") { it.toString() }
-               links.add(NavLink("#$toAnchor", "$number. $text", aClasses = "left"))
-       }
-       
-       private var description: String? = null
-       private var image: String? = null
-       
-       fun addDescription(plainText: String) {
-               description = description.orEmpty() + plainText
-       }
-       
-       fun addImage(path: String, overWrite: Boolean = false) {
-               if (overWrite || image == null)
-                       image = path
-       }
-       
-       fun toPageTitle() = title ?: MISSING_TITLE
-       
-       fun toOpenGraph() = description?.let { desc ->
-               image?.let { image ->
-                       OpenGraphData(desc, image)
-               }
-       }
-       
-       fun toNavBar(): List<NavItem> = listOf(NavLink("#page-top", title ?: MISSING_TITLE, aClasses = "left")) + links.toList()
-       
-       companion object {
-               const val MISSING_TITLE = "Untitled"
-       }
-}
-
-private class ToCHeaderBuilderTag(val level: Int) : BuilderTag<TableOfContentsBuilder> {
-       override fun processTag(env: LexerTagEnvironment<TableOfContentsBuilder, Unit>, param: String?, subNodes: ParserTree) {
-               val label = subNodes.treeToText()
-               val anchor = label.sanitizeAnchor()
-               
-               env.context.addHeader(label, level, anchor)
-       }
-}
-
-private class ToCPropertyBuilderTag(val converter: (String) -> String, val setter: TableOfContentsBuilder.(String) -> Unit) : BuilderTag<TableOfContentsBuilder> {
-       override fun processTag(env: LexerTagEnvironment<TableOfContentsBuilder, Unit>, param: String?, subNodes: ParserTree) {
-               env.context.setter(converter(subNodes.treeToText()))
-       }
-}
-
-fun String.imagePathToOpenGraphValue() = "https://mechyrdia.info/assets/images/${sanitizeLink()}"
-
-enum class ToCBuilderTag(val type: BuilderTag<TableOfContentsBuilder>) {
-       H1(ToCHeaderBuilderTag(0)),
-       H2(ToCHeaderBuilderTag(1)),
-       H3(ToCHeaderBuilderTag(2)),
-       H4(ToCHeaderBuilderTag(3)),
-       H5(ToCHeaderBuilderTag(4)),
-       H6(ToCHeaderBuilderTag(5)),
-       DESC(ToCPropertyBuilderTag({ it }, TableOfContentsBuilder::addDescription)),
-       IMAGE(ToCPropertyBuilderTag(String::imagePathToOpenGraphValue) { addImage(it, false) }),
-       THUMB(ToCPropertyBuilderTag(String::imagePathToOpenGraphValue) { addImage(it, true) }),
-       ;
-       
-       companion object {
-               val asTags = LexerTags(entries.associate { it.name to it.type })
-       }
-}
-
-fun ParserTree.buildToC(builder: TableOfContentsBuilder) {
-       LexerTagEnvironment(
-               builder,
-               ToCBuilderTag.asTags,
-               ToCBuilderProcessor,
-               ToCBuilderProcessor,
-               ToCBuilderProcessor,
-               ToCBuilderProcessor,
-       ).processTree(this)
-}
-
-object RepliesBuilderProcessor : BuilderLexerProcessor<CommentRepliesBuilder>()
-
-class CommentRepliesBuilder {
-       private val repliesTo = mutableSetOf<Id<Comment>>()
-       
-       fun addReplyTag(reply: Id<Comment>) {
-               repliesTo += reply
-       }
-       
-       fun toReplySet() = repliesTo.toSet()
-}
-
-val ID_REGEX = Regex("[A-IL-TVX-Z0-9]{24}")
-fun sanitizeId(html: String) = ID_REGEX.matchEntire(html)?.value
-
-enum class RepliesBuilderTag(val type: BuilderTag<CommentRepliesBuilder>) {
-       REPLY(
-               BuilderTag { env, _, content ->
-                       sanitizeId(content.treeToText())?.let { id ->
-                               env.context.addReplyTag(Id(id))
-                       }
-               }
-       );
-       
-       companion object {
-               val asTags = LexerTags(entries.associate { it.name to it.type })
-       }
-}
-
-fun ParserTree.buildReplies(builder: CommentRepliesBuilder) {
-       LexerTagEnvironment(
-               builder,
-               RepliesBuilderTag.asTags,
-               RepliesBuilderProcessor,
-               RepliesBuilderProcessor,
-               RepliesBuilderProcessor,
-               RepliesBuilderProcessor,
-       ).processTree(this)
-}
-
-fun getReplies(commentContents: String): Set<Id<Comment>> {
-       val builder = CommentRepliesBuilder()
-       commentContents.parseAs(builder, ParserTree::buildReplies)
-       return builder.toReplySet()
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_html.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_html.kt
deleted file mode 100644 (file)
index 614a0ee..0000000
+++ /dev/null
@@ -1,734 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.JsonStorageCodec
-import io.ktor.util.*
-import kotlinx.html.*
-import kotlinx.html.org.w3c.dom.events.Event
-import kotlinx.html.stream.appendHTML
-import kotlinx.serialization.json.JsonPrimitive
-import kotlin.text.toCharArray
-
-typealias HtmlBuilderContext = Unit
-typealias HtmlBuilderSubject = TagConsumer<*>.() -> Any?
-
-context(T)
-operator fun <T : Tag> (TagConsumer<*>.() -> Any?).unaryPlus() = with(HtmlLexerTagConsumer(consumer)) { this@unaryPlus() }
-
-fun (TagConsumer<*>.() -> Any?).toFragment() = StringBuilder()
-       .appendHTML()
-       .also { builder ->
-               with(HtmlLexerTagConsumer(builder)) { this@toFragment() }
-       }
-       .finalize()
-       .toString()
-
-class HtmlLexerTagConsumer private constructor(private val downstream: TagConsumer<*>) : TagConsumer<Unit> {
-       override fun onTagStart(tag: Tag) {
-               downstream.onTagStart(tag)
-       }
-       
-       override fun onTagAttributeChange(tag: Tag, attribute: String, value: String?) {
-               downstream.onTagAttributeChange(tag, attribute, value)
-       }
-       
-       override fun onTagContent(content: CharSequence) {
-               downstream.onTagContent(content)
-       }
-       
-       override fun onTagContentEntity(entity: Entities) {
-               downstream.onTagContentEntity(entity)
-       }
-       
-       override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
-               downstream.onTagContentUnsafe(block)
-       }
-       
-       override fun onTagEvent(tag: Tag, event: String, value: (Event) -> Unit) {
-               downstream.onTagEvent(tag, event, value)
-       }
-       
-       override fun onTagEnd(tag: Tag) {
-               downstream.onTagEnd(tag)
-       }
-       
-       override fun onTagComment(content: CharSequence) {
-               downstream.onTagComment(content)
-       }
-       
-       override fun finalize() {
-               // no-op
-       }
-       
-       companion object {
-               operator fun invoke(downstream: TagConsumer<*>) =
-                       if (downstream is HtmlLexerTagConsumer)
-                               downstream
-                       else
-                               HtmlLexerTagConsumer(downstream)
-       }
-}
-
-context(C)
-operator fun <T, C : TagConsumer<T>> String.unaryPlus() = onTagContent(this)
-
-context(C)
-operator fun <T, C : TagConsumer<T>> Entities.unaryPlus() = onTagContentEntity(this)
-
-fun <T, C : TagConsumer<T>> C.unsafe(block: Unsafe.() -> Unit) = onTagContentUnsafe(block)
-
-fun ParserTree.shouldSplitSections(): Boolean = firstOrNull()?.let {
-       it is ParserTreeNode.Tag && it isTag "h1"
-} == true
-
-fun ParserTree.splitSections(): List<ParserTree> = splitBefore {
-       it is ParserTreeNode.Tag && it isTag "h2"
-}
-
-fun ParserTreeNode.isWhitespace() = when (this) {
-       is ParserTreeNode.Text -> text.isBlank()
-       ParserTreeNode.LineBreak -> true
-       is ParserTreeNode.Tag -> false
-}
-
-fun ParserTree.isWhitespace() = all { it.isWhitespace() }
-
-fun ParserTreeNode.isParagraph(inlineTags: Set<String>): Boolean = when (this) {
-       is ParserTreeNode.Text -> true
-       ParserTreeNode.LineBreak -> false
-       is ParserTreeNode.Tag -> this isTag inlineTags && subNodes.isParagraph(inlineTags)
-}
-
-fun ParserTree.isParagraph(inlineTags: Set<String>): Boolean = any { it.isParagraph(inlineTags) }
-
-fun ParserTree.splitParagraphs(): List<ParserTree> = splitOn { it == ParserTreeNode.LineBreak }
-
-fun ParserTree.toHtmlParagraph(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>) = if (isEmpty())
-       null
-else if (isParagraph(HtmlLexerProcessor.inlineTags)) {
-       val concat = HtmlLexerProcessor.combineInline(env, this)
-       ({ p { +concat } })
-} else
-       HtmlLexerProcessor.combineInline(env, this)
-
-fun ParserTree.splitHtmlParagraphs(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>): List<HtmlBuilderSubject> =
-       splitParagraphs().mapNotNull { paragraph ->
-               paragraph.toHtmlParagraph(env)
-       }
-
-object HtmlLexerProcessor : LexerTagFallback<HtmlBuilderContext, HtmlBuilderSubject>, LexerTextProcessor<HtmlBuilderContext, HtmlBuilderSubject>, LexerLineBreakProcessor<HtmlBuilderContext, HtmlBuilderSubject>, LexerCombiner<HtmlBuilderContext, HtmlBuilderSubject> {
-       val inlineTags = setOf(
-               "b",
-               "i",
-               "u",
-               "s",
-               "sup",
-               "sub",
-               "color",
-               "ipa",
-               "code",
-               "desc",
-               "link",
-               "extlink",
-               "lang",
-               "url",
-               "reply",
-       )
-       
-       override fun processInvalidTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, tag: String, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
-               return {
-                       +if (param == null) "[$tag]" else "[$tag=$param]"
-                       env.processTree(subNodes)()
-                       +"[/$tag]"
-               }
-       }
-       
-       override fun processText(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, text: String): HtmlBuilderSubject {
-               return { +text }
-       }
-       
-       override fun processLineBreak(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>): HtmlBuilderSubject {
-               return {
-                       br()
-                       br()
-               }
-       }
-       
-       override fun processAndCombine(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
-               return combinePage(env, nodes)
-       }
-       
-       fun combinePage(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
-               return if (nodes.shouldSplitSections()) {
-                       val pageParts = nodes.splitSections().map { combineBlock(env, it) }
-                       ({
-                               for (pagePart in pageParts) section { +pagePart }
-                       })
-               } else
-                       combineBlock(env, nodes)
-       }
-       
-       fun combineItem(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
-               return if (nodes.any { it == ParserTreeNode.LineBreak }) {
-                       val paragraphs = nodes.splitHtmlParagraphs(env)
-                       ({
-                               for (paragraph in paragraphs) paragraph()
-                       })
-               } else
-                       combineInline(env, nodes)
-       }
-       
-       fun combineBlock(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
-               return if (nodes.any { it == ParserTreeNode.LineBreak }) {
-                       val paragraphs = nodes.splitHtmlParagraphs(env)
-                       ({
-                               for (paragraph in paragraphs) paragraph()
-                       })
-               } else if (nodes.isParagraph(inlineTags)) {
-                       val concat = combineInline(env, nodes)
-                       ({ p { +concat } })
-               } else
-                       combineInline(env, nodes)
-       }
-       
-       fun combineInline(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
-               return combine(env, nodes.map(env::processNode))
-       }
-       
-       fun combineLayout(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, nodes: ParserTree): HtmlBuilderSubject {
-               return combine(env, nodes.filterNot(ParserTreeNode::isWhitespace).map(env::processNode))
-       }
-       
-       override fun combine(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, subjects: List<HtmlBuilderSubject>): HtmlBuilderSubject {
-               return { for (subject in subjects) subject() }
-       }
-}
-
-fun interface HtmlLexerTag : LexerTagProcessor<HtmlBuilderContext, HtmlBuilderSubject>
-
-class HtmlMetadataLexerTag(val absorb: Boolean) : HtmlLexerTag {
-       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
-               return if (absorb) ({}) else HtmlLexerProcessor.combineInline(env, subNodes)
-       }
-}
-
-fun ParserTree.treeToText(): String = joinToString(separator = "") {
-       when (it) {
-               is ParserTreeNode.Text -> it.text
-               ParserTreeNode.LineBreak -> " "
-               is ParserTreeNode.Tag -> it.subNodes.treeToText()
-       }
-}.trim()
-
-fun interface HtmlTextBodyLexerTag : HtmlLexerTag {
-       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
-               return processTag(env, param, subNodes.treeToText())
-       }
-       
-       fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, innerText: String): HtmlBuilderSubject
-}
-
-typealias TagCreator = TagConsumer<*>.(block: Tag.() -> Unit) -> Unit
-
-fun <T1> (TagConsumer<*>.(T1?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator {
-       return {
-               this@toTagCreator(null, it)
-       }
-}
-
-fun <T1, T2> (TagConsumer<*>.(T1?, T2?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator {
-       return {
-               this@toTagCreator(null, null, it)
-       }
-}
-
-fun <T1, T2, T3> (TagConsumer<*>.(T1?, T2?, T3?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator {
-       return {
-               this@toTagCreator(null, null, null, it)
-       }
-}
-
-enum class HtmlTagMode {
-       INLINE,
-       BLOCK,
-       ITEM,
-       LAYOUT;
-       
-       fun combine(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, subNodes: ParserTree) = when (this) {
-               INLINE -> HtmlLexerProcessor.combineInline(env, subNodes)
-               BLOCK -> HtmlLexerProcessor.combineBlock(env, subNodes)
-               ITEM -> HtmlLexerProcessor.combineItem(env, subNodes)
-               LAYOUT -> HtmlLexerProcessor.combineLayout(env, subNodes)
-       }
-}
-
-class HtmlTagLexerTag(
-       val attributes: (String?) -> Map<String, String> = { _ -> emptyMap() },
-       val tagMode: HtmlTagMode = HtmlTagMode.BLOCK,
-       val tagCreator: TagCreator
-) : HtmlLexerTag {
-       constructor(attributes: Map<String, String>, tagMode: HtmlTagMode = HtmlTagMode.BLOCK, tagCreator: TagCreator) : this({ attributes }, tagMode, tagCreator)
-       
-       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
-               val body = tagMode.combine(env, subNodes)
-               val calculatedAttributes = attributes(param)
-               
-               return {
-                       tagCreator {
-                               for ((name, value) in calculatedAttributes)
-                                       attributes[name] = value
-                               +body
-                       }
-               }
-       }
-}
-
-val NON_ANCHOR_CHAR = Regex("[^a-zA-Z\\d\\-]+")
-fun String.sanitizeAnchor() = replace(NON_ANCHOR_CHAR, "-")
-
-fun ParserTree.treeToAnchorText(): String = treeToText().sanitizeAnchor()
-
-class HtmlHeaderLexerTag(val tagCreator: TagCreator, val anchor: (ParserTree) -> String?) : HtmlLexerTag {
-       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
-               return {
-                       val anchorId = anchor(subNodes)
-                       
-                       anchorId?.let { a { id = it } }
-                       tagCreator {
-                               +subNodes.treeToText()
-                       }
-                       
-                       val anchorHash = anchorId?.let { "#$it" }.orEmpty()
-                       script { unsafe { +"window.checkRedirectTarget(\"$anchorHash\");" } }
-               }
-       }
-}
-
-fun repeatColorDigits(color: String) = when (color.length) {
-       6 -> color
-       3 -> {
-               val (r, g, b) = color.toCharArray()
-               "$r$r$g$g$b$b"
-       }
-       
-       else -> null
-}
-
-fun processColor(param: String?): Map<String, String> = param
-       ?.removePrefix("#")
-       ?.let(::repeatColorDigits)
-       ?.toIntOrNull(16)
-       ?.toString(16)
-       ?.padStart(6, '0')
-       ?.let { mapOf("style" to "color:#$it") }
-       .orEmpty()
-
-private val VALID_ALIGNMENTS = mapOf(
-       "left" to "text-align:left",
-       "right" to "text-align:right",
-       "center" to "text-align:center",
-       "justify" to "text-align:justify;text-align-last:left"
-)
-
-fun processAlign(param: String?): Map<String, String> = param
-       ?.lowercase()
-       ?.let { VALID_ALIGNMENTS[it] }
-       ?.let { mapOf("style" to it) }
-       .orEmpty()
-
-private val VALID_FLOATS = mapOf(
-       "left" to "float:left;max-width:var(--aside-width)",
-       "right" to "float:right;max-width:var(--aside-width)",
-)
-
-fun processFloat(param: String?): Map<String, String> = param
-       ?.lowercase()
-       ?.let { VALID_FLOATS[it] }
-       ?.let { mapOf("style" to it) }
-       .orEmpty()
-
-val NON_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._/]")
-val DOT_CHARS = Regex("\\.+")
-fun String.sanitizeLink() = replace(NON_LINK_CHAR, "").replace(DOT_CHARS, ".")
-
-val NON_EXT_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._:/]")
-fun String.sanitizeExtLink() = replace(NON_EXT_LINK_CHAR, "").replace(DOT_CHARS, ".")
-
-val NON_EXT_IMG_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._/]")
-fun String.sanitizeExtImgLink() = replace(NON_EXT_IMG_LINK_CHAR, "").replace(DOT_CHARS, ".")
-
-fun getSizeParam(tagParam: String?): Pair<Int?, Int?> = tagParam?.let { resolution ->
-       val parts = resolution.split('x')
-       parts.getOrNull(0)?.toIntOrNull() to parts.getOrNull(1)?.toIntOrNull()
-} ?: (null to null)
-
-fun getImageSizeStyleValue(width: Int?, height: Int?) = width?.let { "width: calc(var(--media-size-unit) * $it);" }.orEmpty() + height?.let { "height: calc(var(--media-size-unit) * $it);" }.orEmpty()
-fun getImageSizeAttributes(width: Int?, height: Int?) = " style=\"${getImageSizeStyleValue(width, height)}\""
-
-fun processTableCell(param: String?): Map<String, String> {
-       val (width, height) = getSizeParam(param)
-       return width?.let { mapOf("colspan" to "$it") }.orEmpty() + height?.let { mapOf("rowspan" to "$it") }.orEmpty()
-}
-
-fun String.toInternalUrl() = if (startsWith("/")) "/lore$this" else "./$this"
-fun String.toExternalUrl() = if (startsWith("http:")) "https:${substring(5)}" else this
-
-fun processInternalLink(param: String?): Map<String, String> = param
-       ?.sanitizeLink()
-       ?.toInternalUrl()
-       ?.let { mapOf("href" to it) }
-       .orEmpty()
-
-fun processExternalLink(param: String?): Map<String, String> = param
-       ?.sanitizeExtLink()
-       ?.toExternalUrl()
-       ?.let { mapOf("href" to it) }
-       .orEmpty()
-
-fun processCommentLink(param: String?): Map<String, String> = processExternalLink(param) + mapOf("rel" to "ugc nofollow")
-
-fun processCommentImage(url: String, domain: String) = "https://$domain/${url.sanitizeExtImgLink()}"
-
-enum class FactbookFormattingTag(val type: HtmlLexerTag) {
-       B(HtmlTagLexerTag(attributes = mapOf("style" to "font-weight:bold"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       I(HtmlTagLexerTag(attributes = mapOf("style" to "font-style:italic"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       U(HtmlTagLexerTag(attributes = mapOf("style" to "text-decoration:underline"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       S(HtmlTagLexerTag(attributes = mapOf("style" to "text-decoration:line-through"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       SUP(HtmlTagLexerTag(tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::sup.toTagCreator())),
-       SUB(HtmlTagLexerTag(tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::sub.toTagCreator())),
-       COLOR(HtmlTagLexerTag(attributes = ::processColor, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       IPA(HtmlTagLexerTag(attributes = mapOf("style" to "font-family:DejaVu Sans"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       CODE(HtmlTagLexerTag(attributes = mapOf("style" to "font-family:JetBrains Mono"), tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       CODE_BLOCK(HtmlTagLexerTag(tagCreator = { block ->
-               div {
-                       style = "font-family:JetBrains Mono"
-                       pre {
-                               block()
-                       }
-               }
-       })),
-       BLOCKQUOTE(HtmlTagLexerTag(tagCreator = TagConsumer<*>::blockQuote.toTagCreator())),
-       
-       ERROR(HtmlTagLexerTag(attributes = mapOf("style" to "color: #f00"), tagCreator = TagConsumer<*>::div.toTagCreator())),
-       
-       H1(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h1.toTagCreator()) { null }),
-       H2(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h2.toTagCreator(), ParserTree::treeToAnchorText)),
-       H3(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h3.toTagCreator(), ParserTree::treeToAnchorText)),
-       H4(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h4.toTagCreator(), ParserTree::treeToAnchorText)),
-       H5(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h5.toTagCreator(), ParserTree::treeToAnchorText)),
-       H6(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h6.toTagCreator(), ParserTree::treeToAnchorText)),
-       
-       ALIGN(HtmlTagLexerTag(attributes = ::processAlign, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::div.toTagCreator())),
-       ASIDE(HtmlTagLexerTag(attributes = ::processFloat, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::div.toTagCreator())),
-       
-       DESC(HtmlMetadataLexerTag(absorb = false)),
-       THUMB(HtmlMetadataLexerTag(absorb = true)),
-       
-       IMAGE(HtmlTextBodyLexerTag { _, param, content ->
-               val url = content.sanitizeLink()
-               val (width, height) = getSizeParam(param)
-               val styleValue = getImageSizeStyleValue(width, height)
-               
-               if (url.endsWith(".svg")) {
-                       ({
-                               iframe {
-                                       src = "/assets/images/$url"
-                                       style = styleValue
-                               }
-                       })
-               } else {
-                       ({
-                               script { unsafe { +"window.appendImageThumb(\"/assets/images/$url\", \"$styleValue\");" } }
-                       })
-               }
-       }),
-       MODEL(HtmlTextBodyLexerTag { _, param, content ->
-               val url = content.sanitizeLink()
-               val (width, height) = getSizeParam(param)
-               val sizeStyle = getImageSizeStyleValue(width, height)
-               
-               ({
-                       canvas {
-                               style = sizeStyle
-                               attributes["data-model"] = url
-                       }
-               })
-       }),
-       AUDIO(HtmlTextBodyLexerTag { _, _, content ->
-               val url = content.sanitizeLink()
-               
-               ({
-                       audio {
-                               src = "/assets/sounds/$url"
-                               controls = true
-                       }
-               })
-       }),
-       QUIZ(HtmlTextBodyLexerTag { _, _, content ->
-               val contentJson = JsonStorageCodec.parseToJsonElement(content)
-               
-               ({
-                       script { unsafe { +"window.renderQuiz($contentJson);" } }
-               })
-       }),
-       
-       UL(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::ul.toTagCreator())),
-       OL(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::ol.toTagCreator())),
-       LI(HtmlTagLexerTag(tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::li.toTagCreator())),
-       
-       TABLE(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::table.toTagCreator())),
-       TR(HtmlTagLexerTag(tagMode = HtmlTagMode.LAYOUT, tagCreator = TagConsumer<*>::tr.toTagCreator())),
-       TD(HtmlTagLexerTag(attributes = ::processTableCell, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::td.toTagCreator())),
-       TH(HtmlTagLexerTag(attributes = ::processTableCell, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::th.toTagCreator())),
-       
-       MOMENT(HtmlTextBodyLexerTag { _, _, content ->
-               val epochMilli = content.toLongOrNull()
-               if (epochMilli == null)
-                       ({ +content })
-               else
-                       ({
-                               span(classes = "moment") {
-                                       style = "display:none"
-                                       +"$epochMilli"
-                               }
-                       })
-       }),
-       LINK(HtmlTagLexerTag(attributes = ::processInternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
-       EXTLINK(HtmlTagLexerTag(attributes = ::processExternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
-       ANCHOR(HtmlTextBodyLexerTag { _, _, content ->
-               val url = content.sanitizeAnchor()
-               
-               ({
-                       a {
-                               id = url
-                               attributes["name"] = url
-                       }
-               })
-       }),
-       REDIRECT(HtmlTextBodyLexerTag { _, _, content ->
-               val url = content.toInternalUrl()
-               val jsString = JsonPrimitive(url).toString()
-               
-               ({
-                       script {
-                               unsafe { +"window.factbookRedirect($jsString);" }
-                       }
-               })
-       }),
-       LANG(
-               HtmlLexerTag { _, param, content ->
-                       if ("tylan".equals(param, ignoreCase = true)) {
-                               val tylan = TylanAlphabetFont.tylanToFontAlphabet(content.treeToText())
-                               ({
-                                       span(classes = "lang-tylan") { +tylan }
-                               })
-                       } else if ("thedish".equals(param, ignoreCase = true)) {
-                               val thedish = content.treeToText()
-                               ({
-                                       span(classes = "lang-thedish") { +thedish }
-                               })
-                       } else if ("kishari".equals(param, ignoreCase = true)) {
-                               val kishari = content.treeToText()
-                               ({
-                                       span(classes = "lang-kishari") { +kishari }
-                               })
-                       } else if ("pokhval".equals(param, ignoreCase = true) || "pochval".equals(param, ignoreCase = true) || "pokhwal".equals(param, ignoreCase = true)) {
-                               val pokhwal = PokhwalishAlphabetFont.pokhwalToFontAlphabet(content.treeToText())
-                               ({
-                                       span(classes = "lang-pokhval") { +pokhwal }
-                               })
-                       } else {
-                               val foreign = content.treeToText()
-                               ({
-                                       +foreign
-                               })
-                       }
-               }
-       ),
-       ALPHABET(
-               HtmlTextBodyLexerTag { _, param, content ->
-                       if ("mechyrdian".equals(content, ignoreCase = true))
-                               ({
-                                       div(classes = "mechyrdia-sans-box") {
-                                               p { +"Input Text:" }
-                                               textArea(classes = "input-box") { spellCheck = false }
-                                               p { +"Font Options:" }
-                                               ul {
-                                                       li {
-                                                               label {
-                                                                       checkBoxInput(classes = "bold-option")
-                                                                       +Entities.nbsp
-                                                                       +"Bold"
-                                                               }
-                                                       }
-                                                       li {
-                                                               label {
-                                                                       checkBoxInput(classes = "ital-option")
-                                                                       +Entities.nbsp
-                                                                       +"Italic"
-                                                               }
-                                                       }
-                                                       li {
-                                                               label {
-                                                                       +"Align"
-                                                                       +Entities.nbsp
-                                                                       select(classes = "align-opts") {
-                                                                               option {
-                                                                                       selected = true
-                                                                                       value = "left"
-                                                                                       +"Left"
-                                                                               }
-                                                                               option {
-                                                                                       value = "center"
-                                                                                       +"Center"
-                                                                               }
-                                                                               option {
-                                                                                       value = "right"
-                                                                                       +"Right"
-                                                                               }
-                                                                       }
-                                                               }
-                                                       }
-                                               }
-                                               p { +"Rendered Text:" }
-                                               img(classes = "output-img") {
-                                                       style = "display:block;max-width:100%"
-                                               }
-                                       }
-                               })
-                       else if ("tylan".equals(content, ignoreCase = true))
-                               ({
-                                       div(classes = "tylan-alphabet-box") {
-                                               p { +"Latin Alphabet:" }
-                                               textArea(classes = "input-box") { spellCheck = false }
-                                               p { +"Tylan Alphabet:" }
-                                               textArea(classes = "output-box lang-tylan") { readonly = true }
-                                       }
-                               })
-                       else if ("thedish".equals(content, ignoreCase = true))
-                               ({
-                                       div(classes = "thedish-alphabet-box") {
-                                               p { +"Latin Alphabet:" }
-                                               textArea(classes = "input-box") { spellCheck = false }
-                                               p { +"Thedish Alphabet:" }
-                                               textArea(classes = "output-box lang-thedish") { readonly = true }
-                                       }
-                               })
-                       else if ("kishari".equals(content, ignoreCase = true))
-                               ({
-                                       div(classes = "kishari-alphabet-box") {
-                                               p { +"Latin Alphabet:" }
-                                               textArea(classes = "input-box") { spellCheck = false }
-                                               p { +"Kishari Alphabet:" }
-                                               textArea(classes = "output-box lang-kishari") { readonly = true }
-                                       }
-                               })
-                       else if ("pokhval".equals(param, ignoreCase = true) || "pochval".equals(param, ignoreCase = true) || "pokhwal".equals(param, ignoreCase = true))
-                               ({
-                                       div(classes = "pokhwal-alphabet-box") {
-                                               p { +"Latin Alphabet:" }
-                                               textArea(classes = "input-box") { spellCheck = false }
-                                               p { +"Pokhwalish Alphabet:" }
-                                               textArea(classes = "output-box lang-pokhwal") { readonly = true }
-                                       }
-                               })
-                       else ({})
-               }
-       ),
-       VOCAB(HtmlTextBodyLexerTag { _, _, content ->
-               val contentJson = JsonStorageCodec.parseToJsonElement(content)
-               
-               ({
-                       script { unsafe { +"window.renderVocab($contentJson);" } }
-               })
-       }),
-       ;
-       
-       companion object {
-               val asTags = LexerTags(entries.associate { it.name to it.type })
-       }
-}
-
-fun ParserTree.toFactbookHtml(): TagConsumer<*>.() -> Any? {
-       return LexerTagEnvironment(
-               Unit,
-               FactbookFormattingTag.asTags,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-       ).processTree(this)
-}
-
-enum class CommentFormattingTag(val type: HtmlLexerTag) {
-       B(FactbookFormattingTag.B.type),
-       I(FactbookFormattingTag.I.type),
-       U(FactbookFormattingTag.U.type),
-       S(FactbookFormattingTag.S.type),
-       SUP(FactbookFormattingTag.SUP.type),
-       SUB(FactbookFormattingTag.SUB.type),
-       COLOR(FactbookFormattingTag.COLOR.type),
-       IPA(FactbookFormattingTag.IPA.type),
-       CODE(FactbookFormattingTag.CODE.type),
-       CODE_BLOCK(FactbookFormattingTag.CODE_BLOCK.type),
-       
-       ERROR(HtmlTagLexerTag(attributes = mapOf("style" to "color: #f00"), tagCreator = TagConsumer<*>::div.toTagCreator())),
-       
-       ALIGN(FactbookFormattingTag.ALIGN.type),
-       ASIDE(FactbookFormattingTag.ASIDE.type),
-       
-       UL(FactbookFormattingTag.UL.type),
-       OL(FactbookFormattingTag.OL.type),
-       LI(FactbookFormattingTag.LI.type),
-       
-       TABLE(FactbookFormattingTag.TABLE.type),
-       TR(FactbookFormattingTag.TR.type),
-       TD(FactbookFormattingTag.TD.type),
-       TH(FactbookFormattingTag.TH.type),
-       
-       URL(HtmlTagLexerTag(attributes = ::processCommentLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
-       
-       LANG(FactbookFormattingTag.LANG.type),
-       
-       IMGBB(HtmlTextBodyLexerTag { _, tagParam, content ->
-               val imageUrl = processCommentImage(content, "i.ibb.co")
-               val (width, height) = getSizeParam(tagParam)
-               val sizeStyle = getImageSizeStyleValue(width, height)
-               
-               ({
-                       img(src = imageUrl) { style = sizeStyle }
-               })
-       }),
-       
-       REPLY(HtmlTextBodyLexerTag { _, _, content ->
-               val id = sanitizeId(content)
-               
-               if (id == null)
-                       ({ +">>$content" })
-               else
-                       ({
-                               a(href = "/comment/view/$id") {
-                                       rel = "ugc"
-                                       +">>$id"
-                               }
-                       })
-       }),
-       
-       QUOTE(FactbookFormattingTag.BLOCKQUOTE.type),
-       EPOCH(FactbookFormattingTag.MOMENT.type),
-       ;
-       
-       companion object {
-               val asTags = LexerTags(entries.associate { it.name to it.type })
-       }
-}
-
-fun ParserTree.toCommentHtml(): TagConsumer<*>.() -> Any? {
-       return LexerTagEnvironment(
-               Unit,
-               CommentFormattingTag.asTags,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-       ).processTree(this)
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer.kt
deleted file mode 100644 (file)
index 61cb796..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-package info.mechyrdia.lore
-
-class LexerTagEnvironment<TContext, TSubject>(
-       val context: TContext,
-       private val processTags: LexerTags<TContext, TSubject>,
-       private val processText: LexerTextProcessor<TContext, TSubject>,
-       private val processBreak: LexerLineBreakProcessor<TContext, TSubject>,
-       private val processInvalidTag: LexerTagFallback<TContext, TSubject>,
-       private val combiner: LexerCombiner<TContext, TSubject>
-) {
-       fun processTree(parserTree: ParserTree): TSubject {
-               return combiner.processAndCombine(this, parserTree)
-       }
-       
-       fun processNode(parserTreeNode: ParserTreeNode): TSubject {
-               return when (parserTreeNode) {
-                       is ParserTreeNode.Text -> processText.processText(this, parserTreeNode.text)
-                       ParserTreeNode.LineBreak -> processBreak.processLineBreak(this)
-                       is ParserTreeNode.Tag -> processTags[parserTreeNode.tag]?.processTag(this, parserTreeNode.param, parserTreeNode.subNodes)
-                               ?: processInvalidTag.processInvalidTag(this, parserTreeNode.tag, parserTreeNode.param, parserTreeNode.subNodes)
-               }
-       }
-}
-
-@JvmInline
-value class LexerTags<TContext, TSubject> private constructor(private val tags: Map<String, LexerTagProcessor<TContext, TSubject>>) {
-       operator fun get(name: String) = tags[name.lowercase()]
-       
-       operator fun plus(other: LexerTags<TContext, TSubject>) = LexerTags(tags + other.tags)
-       
-       companion object {
-               fun <TContext, TSubject> empty() = LexerTags<TContext, TSubject>(emptyMap())
-               
-               operator fun <TContext, TSubject> invoke(tags: Map<String, LexerTagProcessor<TContext, TSubject>>) = LexerTags(tags.mapKeys { (name, _) -> name.lowercase() })
-       }
-}
-
-fun interface LexerTagProcessor<TContext, TSubject> {
-       fun processTag(env: LexerTagEnvironment<TContext, TSubject>, param: String?, subNodes: ParserTree): TSubject
-}
-
-fun interface LexerTagFallback<TContext, TSubject> {
-       fun processInvalidTag(env: LexerTagEnvironment<TContext, TSubject>, tag: String, param: String?, subNodes: ParserTree): TSubject
-}
-
-fun interface LexerTextProcessor<TContext, TSubject> {
-       fun processText(env: LexerTagEnvironment<TContext, TSubject>, text: String): TSubject
-}
-
-fun interface LexerLineBreakProcessor<TContext, TSubject> {
-       fun processLineBreak(env: LexerTagEnvironment<TContext, TSubject>): TSubject
-}
-
-fun interface LexerCombiner<TContext, TSubject> {
-       fun processAndCombine(env: LexerTagEnvironment<TContext, TSubject>, nodes: ParserTree): TSubject {
-               return combine(env, nodes.map(env::processNode))
-       }
-       
-       fun combine(env: LexerTagEnvironment<TContext, TSubject>, subjects: List<TSubject>): TSubject
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer_async.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer_async.kt
deleted file mode 100644 (file)
index 6889efc..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-package info.mechyrdia.lore
-
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
-
-data class AsyncLexerTagEnvironment<TContext, TSubject>(
-       val context: TContext,
-       private val processTags: AsyncLexerTags<TContext, TSubject>,
-       private val processText: AsyncLexerTextProcessor<TContext, TSubject>,
-       private val processBreak: AsyncLexerLineBreakProcessor<TContext, TSubject>,
-       private val processInvalidTag: AsyncLexerTagFallback<TContext, TSubject>,
-       private val combiner: AsyncLexerCombiner<TContext, TSubject>
-) {
-       suspend fun processTree(parserTree: ParserTree): TSubject {
-               return combiner.processAndCombine(this, parserTree)
-       }
-       
-       suspend fun processNode(parserTreeNode: ParserTreeNode): TSubject {
-               return when (parserTreeNode) {
-                       is ParserTreeNode.Text -> processText.processText(this, parserTreeNode.text)
-                       ParserTreeNode.LineBreak -> processBreak.processLineBreak(this)
-                       is ParserTreeNode.Tag -> processTags[parserTreeNode.tag]?.processTag(this, parserTreeNode.param, parserTreeNode.subNodes)
-                               ?: processInvalidTag.processInvalidTag(this, parserTreeNode.tag, parserTreeNode.param, parserTreeNode.subNodes)
-               }
-       }
-}
-
-@JvmInline
-value class AsyncLexerTags<TContext, TSubject> private constructor(private val tags: Map<String, AsyncLexerTagProcessor<TContext, TSubject>>) {
-       operator fun get(name: String) = tags[name.lowercase()]
-       
-       operator fun plus(other: AsyncLexerTags<TContext, TSubject>) = AsyncLexerTags(tags + other.tags)
-       
-       companion object {
-               fun <TContext, TSubject> empty() = AsyncLexerTags<TContext, TSubject>(emptyMap())
-               
-               operator fun <TContext, TSubject> invoke(tags: Map<String, AsyncLexerTagProcessor<TContext, TSubject>>) = AsyncLexerTags(tags.mapKeys { (name, _) -> name.lowercase() })
-       }
-}
-
-fun interface AsyncLexerTagProcessor<TContext, TSubject> {
-       suspend fun processTag(env: AsyncLexerTagEnvironment<TContext, TSubject>, param: String?, subNodes: ParserTree): TSubject
-}
-
-fun interface AsyncLexerTagFallback<TContext, TSubject> {
-       suspend fun processInvalidTag(env: AsyncLexerTagEnvironment<TContext, TSubject>, tag: String, param: String?, subNodes: ParserTree): TSubject
-}
-
-fun interface AsyncLexerTextProcessor<TContext, TSubject> {
-       suspend fun processText(env: AsyncLexerTagEnvironment<TContext, TSubject>, text: String): TSubject
-}
-
-fun interface AsyncLexerLineBreakProcessor<TContext, TSubject> {
-       suspend fun processLineBreak(env: AsyncLexerTagEnvironment<TContext, TSubject>): TSubject
-}
-
-fun interface AsyncLexerCombiner<TContext, TSubject> {
-       suspend fun processAndCombine(env: AsyncLexerTagEnvironment<TContext, TSubject>, nodes: ParserTree): TSubject {
-               return combine(env, coroutineScope {
-                       nodes.map {
-                               async { env.processNode(it) }
-                       }.awaitAll()
-               })
-       }
-       
-       suspend fun combine(env: AsyncLexerTagEnvironment<TContext, TSubject>, subjects: List<TSubject>): TSubject
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt
deleted file mode 100644 (file)
index 093a91d..0000000
+++ /dev/null
@@ -1,116 +0,0 @@
-package info.mechyrdia.lore
-
-typealias PlainTextBuilderContext = Unit
-typealias PlainTextBuilderSubject = String
-
-enum class PlainTextTagBehavior {
-       PASS_THROUGH,
-       PASS_THROUGH_SPACED,
-       ABSORB
-}
-
-abstract class PlainTextFormattingProcessor : LexerTagFallback<PlainTextBuilderContext, PlainTextBuilderSubject>, LexerTextProcessor<PlainTextBuilderContext, PlainTextBuilderSubject>, LexerLineBreakProcessor<PlainTextBuilderContext, PlainTextBuilderSubject>, LexerCombiner<PlainTextBuilderContext, PlainTextBuilderSubject> {
-       protected abstract fun getTagBehavior(tag: String): PlainTextTagBehavior
-       protected open fun replaceLineBreak(): String = " "
-       
-       override fun processInvalidTag(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, tag: String, param: String?, subNodes: ParserTree): PlainTextBuilderSubject {
-               return when (getTagBehavior(tag.lowercase())) {
-                       PlainTextTagBehavior.PASS_THROUGH -> env.processTree(subNodes)
-                       PlainTextTagBehavior.PASS_THROUGH_SPACED -> env.processTree(subNodes).let { " $it " }
-                       PlainTextTagBehavior.ABSORB -> ""
-               }
-       }
-       
-       override fun processText(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, text: String): PlainTextBuilderSubject {
-               return text
-       }
-       
-       override fun processLineBreak(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>): PlainTextBuilderSubject {
-               return replaceLineBreak()
-       }
-       
-       override fun combine(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, subjects: List<PlainTextBuilderSubject>): PlainTextBuilderSubject {
-               return subjects.joinToString(separator = "")
-       }
-}
-
-object PlainTextFormattingTag {
-       val asTags = LexerTags.empty<PlainTextBuilderContext, PlainTextBuilderSubject>()
-}
-
-object PlainTextProcessor : PlainTextFormattingProcessor() {
-       private val inlineTags = mapOf(
-               "b" to false,
-               "i" to false,
-               "u" to false,
-               "s" to false,
-               "color" to false,
-               "ipa" to false,
-               "code" to false,
-               "h1" to false,
-               "h2" to false,
-               "h3" to false,
-               "h4" to false,
-               "h5" to false,
-               "h6" to false,
-               "align" to false,
-               "aside" to false,
-               "desc" to false,
-               "link" to false,
-               "extlink" to false,
-               "lang" to false,
-               "sup" to true,
-               "sub" to true,
-               "quote" to true,
-               "blockquote" to true,
-               "ul" to true,
-               "ol" to true,
-               "li" to true,
-               "table" to true,
-               "tr" to true,
-               "td" to true,
-               "th" to true,
-       )
-       
-       override fun getTagBehavior(tag: String): PlainTextTagBehavior {
-               return when (inlineTags[tag]) {
-                       false -> PlainTextTagBehavior.PASS_THROUGH
-                       true -> PlainTextTagBehavior.PASS_THROUGH_SPACED
-                       null -> PlainTextTagBehavior.ABSORB
-               }
-       }
-}
-
-enum class CommentPlainTextFormattingTag(val type: LexerTagProcessor<PlainTextBuilderContext, PlainTextBuilderSubject>) {
-       REPLY(LexerTagProcessor { env, _, subNodes ->
-               val replyContent = env.processTree(subNodes)
-               ">>$replyContent"
-       }),
-       ;
-       
-       companion object {
-               val asTags = LexerTags(entries.associate { it.name to it.type })
-       }
-}
-
-fun ParserTree.toFactbookPlainText(): String {
-       return LexerTagEnvironment(
-               Unit,
-               PlainTextFormattingTag.asTags,
-               PlainTextProcessor,
-               PlainTextProcessor,
-               PlainTextProcessor,
-               PlainTextProcessor,
-       ).processTree(this)
-}
-
-fun ParserTree.toCommentPlainText(): String {
-       return LexerTagEnvironment(
-               Unit,
-               CommentPlainTextFormattingTag.asTags,
-               PlainTextProcessor,
-               PlainTextProcessor,
-               PlainTextProcessor,
-               PlainTextProcessor,
-       ).processTree(this)
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess.kt
deleted file mode 100644 (file)
index db892fd..0000000
+++ /dev/null
@@ -1,479 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.JsonStorageCodec
-import info.mechyrdia.data.StoragePath
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
-import java.time.Instant
-import kotlin.math.roundToInt
-
-class PreProcessorContext private constructor(
-       val variables: MutableMap<String, ParserTree>,
-       val parent: PreProcessorContext? = null,
-) {
-       constructor(parent: PreProcessorContext? = null, vararg variables: Pair<String, ParserTree>) : this(mutableMapOf(*variables), parent)
-       
-       operator fun get(name: String): ParserTree = variables[name] ?: parent?.get(name) ?: formatErrorToParserTree("Unable to resolve variable $name")
-       
-       operator fun set(name: String, value: ParserTree) {
-               if (parent != null && name in parent)
-                       parent[name] = value
-               else
-                       variables[name] = value
-       }
-       
-       fun setGlobal(name: String, value: ParserTree) {
-               if (parent != null)
-                       parent.setGlobal(name, value)
-               else
-                       variables[name] = value
-       }
-       
-       fun setLocal(name: String, value: ParserTree) {
-               variables[name] = value
-       }
-       
-       operator fun contains(name: String): Boolean = name in variables || (parent?.contains(name) == true)
-       
-       operator fun plus(other: Map<String, ParserTree>) = PreProcessorContext(other.toMutableMap(), this)
-       
-       fun toMap(): Map<String, ParserTree> = parent?.toMap().orEmpty() + variables
-       
-       companion object {
-               operator fun invoke(variables: Map<String, ParserTree>, parent: PreProcessorContext? = null) = PreProcessorContext(variables.toMutableMap(), parent)
-               
-               const val PAGE_PATH_KEY = "PAGE_PATH"
-               const val INSTANT_NOW_KEY = "INSTANT_NOW"
-               
-               context(ApplicationCall)
-               fun defaults() = defaults(StoragePath(request.path()))
-               
-               fun defaults(lorePath: StoragePath) = defaults(lorePath.elements.drop(1))
-               
-               fun defaults(lorePath: List<String>) = mapOf(
-                       PAGE_PATH_KEY to "/${lorePath.joinToString(separator = "/")}".textToTree(),
-                       INSTANT_NOW_KEY to Instant.now().toEpochMilli().toString().textToTree(),
-               )
-       }
-}
-
-typealias PreProcessorSubject = ParserTree
-
-object PreProcessorUtils : AsyncLexerTagFallback<PreProcessorContext, PreProcessorSubject>, AsyncLexerTextProcessor<PreProcessorContext, PreProcessorSubject>, AsyncLexerLineBreakProcessor<PreProcessorContext, PreProcessorSubject>, AsyncLexerCombiner<PreProcessorContext, PreProcessorSubject> {
-       override suspend fun processInvalidTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, tag: String, param: String?, subNodes: ParserTree): PreProcessorSubject {
-               return listOf(
-                       ParserTreeNode.Tag(
-                               tag = tag,
-                               param = param,
-                               subNodes = env.processTree(subNodes)
-                       )
-               )
-       }
-       
-       override suspend fun processText(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, text: String): PreProcessorSubject {
-               return text.textToTree()
-       }
-       
-       override suspend fun processLineBreak(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): PreProcessorSubject {
-               return listOf(ParserTreeNode.LineBreak)
-       }
-       
-       override suspend fun combine(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, subjects: List<PreProcessorSubject>): PreProcessorSubject {
-               return subjects.flatten()
-       }
-       
-       fun withContext(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, newContext: PreProcessorContext): AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject> {
-               return env.copy(context = newContext)
-       }
-       
-       suspend fun processWithContext(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, newContext: PreProcessorContext, input: ParserTree): ParserTree {
-               return withContext(env, newContext).processTree(input)
-       }
-       
-       fun indexTree(tree: ParserTree, index: List<String>): ParserTree {
-               if (index.isEmpty()) return tree
-               val tags = tree.filterIsInstance<ParserTreeNode.Tag>()
-               if (tags.isEmpty()) return formatErrorToParserTree("Cannot index into empty input value")
-               
-               val head = index.first()
-               val tail = index.drop(1)
-               
-               val firstTag = tags.first()
-               return if (firstTag isTag "item" && firstTag.param == null) {
-                       head.toDoubleOrNull()?.roundToInt()?.let { listIndex ->
-                               tree.asPreProcessorList().getOrNull(listIndex)
-                       }?.let { indexTree(it, tail) }.formatError("Index $head is not present in input value")
-               } else if (firstTag isTag "arg" && firstTag.param != null) {
-                       tree.asPreProcessorMap()[head]?.let { indexTree(it, tail) }.formatError("Index $head is not present in input value")
-               } else formatErrorToParserTree("Cannot index into non-collection input value")
-       }
-}
-
-fun interface PreProcessorLexerTag : AsyncLexerTagProcessor<PreProcessorContext, PreProcessorSubject>
-
-inline fun <T : Any> T?.requireParam(tag: String, block: (T) -> ParserTree): ParserTree {
-       return if (this == null)
-               formatErrorToParserTree("Parameter is required for tag $tag")
-       else block(this)
-}
-
-inline fun String?.forbidParam(tag: String, block: () -> ParserTree): ParserTree {
-       return if (this != null)
-               formatErrorToParserTree("Parameter is forbidden for tag $tag")
-       else block()
-}
-
-fun formatErrorToParserTree(error: String): ParserTree {
-       return listOf(ParserTreeNode.Tag("error", null, listOf(ParserTreeNode.Text(error))))
-}
-
-fun ParserTree?.formatError(error: String): ParserTree {
-       return this ?: formatErrorToParserTree(error)
-}
-
-fun ParserTree.isNull() = all { it.isWhitespace() || (it is ParserTreeNode.Tag && it isTag "error") }
-
-fun String.textToTree(): ParserTree = listOf(ParserTreeNode.Text(this))
-
-fun interface PreProcessorFunction {
-       suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree
-}
-
-interface PreProcessorFunctionProvider : PreProcessorLexerTag {
-       val tagName: String
-       
-       suspend fun provideFunction(param: String?): PreProcessorFunction?
-       
-       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, param: String?, subNodes: ParserTree): PreProcessorSubject {
-               return param?.let { provideFunction(it) }.requireParam(tagName) {
-                       val args = subNodes.asPreProcessorMap().mapValuesSuspend { _, value -> env.processTree(value) }
-                       val ctx = PreProcessorContext(args, env.context)
-                       
-                       val func = provideFunction(param) ?: return emptyList()
-                       func.execute(PreProcessorUtils.withContext(env, ctx))
-               }
-       }
-}
-
-abstract class PreProcessorFunctionLibrary(override val tagName: String) : PreProcessorFunctionProvider {
-       abstract val functions: Map<String, PreProcessorFunction>
-       
-       override suspend fun provideFunction(param: String?) = param?.let { functions[it] }
-       
-       companion object {
-               operator fun invoke(tagName: String, library: Map<String, PreProcessorFunction>) = object : PreProcessorFunctionLibrary(tagName) {
-                       override val functions: Map<String, PreProcessorFunction> = library
-               }
-       }
-}
-
-@JvmInline
-value class PreProcessorVariableFunction(private val variable: String) : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               return env.processTree(env.context[variable])
-       }
-}
-
-object PreProcessorVariableInvoker : PreProcessorFunctionProvider {
-       override val tagName: String = "env"
-       
-       override suspend fun provideFunction(param: String?): PreProcessorFunction? {
-               return param?.let { PreProcessorVariableFunction(it) }
-       }
-}
-
-@JvmInline
-value class PreProcessorScopeFilter(private val variable: String) : PreProcessorFilter {
-       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               return env.copy(context = env.context + env.context[variable].asPreProcessorMap()).processTree(input)
-       }
-}
-
-object PreProcessorScopeInvoker : PreProcessorFilterProvider {
-       override val tagName: String = "scope"
-       
-       override suspend fun provideFilter(param: String?): PreProcessorFilter? {
-               return param?.let { PreProcessorScopeFilter(it) }
-       }
-}
-
-fun interface PreProcessorFilter {
-       suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree
-}
-
-interface PreProcessorFilterProvider : PreProcessorLexerTag {
-       val tagName: String
-       
-       suspend fun provideFilter(param: String?): PreProcessorFilter?
-       
-       override suspend fun processTag(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, param: String?, subNodes: ParserTree): PreProcessorSubject {
-               return param?.let { provideFilter(it) }.requireParam(tagName) {
-                       val filter = provideFilter(param) ?: return emptyList()
-                       filter.execute(subNodes, env)
-               }
-       }
-}
-
-abstract class PreProcessorFilterLibrary(override val tagName: String) : PreProcessorFilterProvider {
-       abstract val filters: Map<String, PreProcessorFilter>
-       
-       override suspend fun provideFilter(param: String?) = param?.let { filters[it] }
-       
-       companion object {
-               operator fun invoke(tagName: String, library: Map<String, PreProcessorFilter>) = object : PreProcessorFilterLibrary(tagName) {
-                       override val filters: Map<String, PreProcessorFilter> = library
-               }
-       }
-}
-
-fun ParserTree.asPreProcessorList(): List<ParserTree> = mapNotNull {
-       if (it !is ParserTreeNode.Tag || it isNotTag "item" || it.param != null)
-               null
-       else
-               it.subNodes
-}
-
-fun ParserTree.asPreProcessorMap(): Map<String, ParserTree> = mapNotNull {
-       if (it !is ParserTreeNode.Tag || it isNotTag "arg" || it.param == null)
-               null
-       else
-               it.param to it.subNodes
-}.toMap()
-
-suspend fun <T, R> List<T>.mapSuspend(processor: suspend (T) -> R) = coroutineScope {
-       map {
-               async {
-                       processor(it)
-               }
-       }.awaitAll()
-}
-
-suspend fun <K, V, R> Map<K, V>.mapValuesSuspend(processor: suspend (K, V) -> R) = coroutineScope {
-       map { (k, v) ->
-               async {
-                       k to processor(k, v)
-               }
-       }.awaitAll().toMap()
-}
-
-enum class PreProcessorTags(val type: PreProcessorLexerTag) {
-       EVAL(PreProcessorLexerTag { env, param, subNodes ->
-               val times = param?.toDoubleOrNull()?.roundToInt() ?: 1
-               
-               var tree = subNodes
-               repeat(times) {
-                       tree = env.processTree(tree)
-               }
-               tree
-       }),
-       LAZY(PreProcessorLexerTag { _, param, subNodes ->
-               param.forbidParam("lazy") { subNodes }
-       }),
-       VAL(PreProcessorLexerTag { env, param, subNodes ->
-               param.forbidParam("val") {
-                       env.processTree(subNodes).treeToText().textToTree()
-               }
-       }),
-       VAR(PreProcessorLexerTag { env, param, subNodes ->
-               param.forbidParam("var") {
-                       env.context[env.processTree(subNodes).treeToText()]
-               }
-       }),
-       DEFAULT(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("var") { varName ->
-                       if (varName in env.context)
-                               env.context[varName]
-                       else env.processTree(subNodes)
-               }
-       }),
-       SET_PARAM(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("var") { varName ->
-                       val paramValue = env.context[varName].treeToText()
-                       val withParams = subNodes.map { node ->
-                               if (node is ParserTreeNode.Tag && node.param == null)
-                                       node.copy(param = paramValue)
-                               else node
-                       }
-                       env.processTree(withParams)
-               }
-       }),
-       ENV(PreProcessorVariableInvoker),
-       SCOPE(PreProcessorScopeInvoker),
-       SET(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("set") { varName ->
-                       env.context[varName] = env.processTree(subNodes)
-                       emptyList()
-               }
-       }),
-       SET_GLOBAL(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("set_global") { varName ->
-                       env.context.setGlobal(varName, env.processTree(subNodes))
-                       emptyList()
-               }
-       }),
-       SET_LOCAL(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("set_local") { varName ->
-                       env.context.setLocal(varName, env.processTree(subNodes))
-                       emptyList()
-               }
-       }),
-       INDEX(PreProcessorLexerTag { env, param, subNodes ->
-               val inputList = env.processTree(subNodes).asPreProcessorList()
-               
-               (param?.toDoubleOrNull() ?: param?.let {
-                       env.processTree(env.context[param]).treeToNumberOrNull(String::toDoubleOrNull)
-               })?.roundToInt().requireParam("index") { index ->
-                       inputList.getOrNull(index).formatError("Index $index is not present in input list")
-               }
-       }),
-       MEMBER(PreProcessorLexerTag { env, param, subNodes ->
-               param?.split('.').requireParam("member") { index ->
-                       PreProcessorUtils.indexTree(env.processTree(subNodes), index)
-               }
-       }),
-       FOR_EACH(PreProcessorLexerTag { env, param, subNodes ->
-               val itemToContext: (ParserTree) -> Map<String, ParserTree> = if (param == null)
-                       ParserTree::asPreProcessorMap
-               else ({ mapOf(param to it) })
-               
-               val subTags = subNodes.filterIsInstance<ParserTreeNode.Tag>()
-               val list = subTags.singleOrNull { it isTag "in" }?.subNodes
-                       ?.let { env.processTree(it) }
-                       ?.asPreProcessorList()
-               
-               val body = subTags.singleOrNull { it isTag "do" }?.subNodes
-               if (list != null && body != null)
-                       list.mapSuspend { item ->
-                               PreProcessorUtils.processWithContext(env, env.context + itemToContext(item), body)
-                       }.flatten()
-               else formatErrorToParserTree("Expected child tag [in] to take list input and child tag [do] to take loop body")
-       }),
-       MAP(PreProcessorLexerTag { env, param, subNodes ->
-               val itemToContext: (ParserTree) -> Map<String, ParserTree> = if (param == null)
-                       ParserTree::asPreProcessorMap
-               else ({ mapOf(param to it) })
-               
-               val subTags = subNodes.filterIsInstance<ParserTreeNode.Tag>()
-               val list = subTags.singleOrNull { it isTag "in" }?.subNodes
-                       ?.let { env.processTree(it) }
-                       ?.asPreProcessorList()
-               
-               val body = subTags.singleOrNull { it isTag "do" }?.subNodes
-               if (list != null && body != null)
-                       list.mapSuspend { item ->
-                               ParserTreeNode.Tag("item", null, PreProcessorUtils.processWithContext(env, env.context + itemToContext(item), body))
-                       }
-               else formatErrorToParserTree("Expected child tag [in] to take list input and child tag [do] to take loop body")
-       }),
-       IF(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("if") { boolVar ->
-                       env.context[boolVar].treeToBooleanOrNull()?.let {
-                               if (it) env.processTree(subNodes) else emptyList()
-                       }.formatError("Expected variable $boolVar to contain boolean value")
-               }
-       }),
-       UNLESS(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("unless") { boolVar ->
-                       env.context[boolVar].treeToBooleanOrNull()?.let {
-                               if (it) emptyList() else env.processTree(subNodes)
-                       }.formatError("Expected variable $boolVar to contain boolean value")
-               }
-       }),
-       LET(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("let") { varName ->
-                       if (varName in env.context && !env.context[varName].isNull())
-                               env.processTree(subNodes)
-                       else emptyList()
-               }
-       }),
-       FALLBACK(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("fallback") { varName ->
-                       if (varName !in env.context || env.context[varName].isNull())
-                               env.processTree(subNodes)
-                       else emptyList()
-               }
-       }),
-       MATH(PreProcessorMathOperators),
-       LOGIC(PreProcessorLogicOperator),
-       TEST(PreProcessorInputTest),
-       JSON_PARSE(PreProcessorLexerTag { _, param, subNodes ->
-               param.forbidParam("json_parse") {
-                       JsonStorageCodec.parseToJsonElement(subNodes.treeToText()).toPreProcessTree()
-               }
-       }),
-       JSON_STRINGIFY(PreProcessorLexerTag { env, param, subNodes ->
-               param.forbidParam("json_stringify") {
-                       env.processTree(subNodes).toPreProcessJson().toString().textToTree()
-               }
-       }),
-       FUNCTION(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("function") { scriptName ->
-                       PreProcessorScriptLoader.runScriptSafe(scriptName, env.processTree(subNodes).asPreProcessorMap(), env) {
-                               it.renderInBBCode()
-                       }
-               }
-       }),
-       FILTER(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("filter") { scriptName ->
-                       PreProcessorScriptLoader.runScriptSafe(scriptName, env.processTree(subNodes), env) {
-                               it.renderInBBCode()
-                       }
-               }
-       }),
-       WITH_DATA_FILE(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("with_data_file") { dataFileName ->
-                       try {
-                               val args = FactbookLoader.loadFactbookContext(dataFileName.split('/'))
-                               env.copy(context = env.context + args).processTree(subNodes)
-                       } catch (ex: Exception) {
-                               ex.renderInBBCode()
-                       }
-               }
-       }),
-       IMPORT(PreProcessorLexerTag { env, param, subNodes ->
-               param.requireParam("import") { templateName ->
-                       PreProcessorTemplateLoader.runTemplateWith(templateName, env.processTree(subNodes).asPreProcessorMap())
-               }
-       }),
-       INCLUDE(PreProcessorLexerTag { env, param, subNodes ->
-               param.forbidParam("include") {
-                       PreProcessorTemplateLoader.runTemplateHere(env.processTree(subNodes).treeToText(), env)
-               }
-       }),
-       TEMPLATE(PreProcessorLexerTag { env, param, subNodes ->
-               param.forbidParam("template") {
-                       PreProcessorTemplateLoader.loadTemplate(env.processTree(subNodes).treeToText())
-               }
-       }),
-       ;
-       
-       companion object {
-               val asTags = AsyncLexerTags(entries.associate { it.name to it.type })
-       }
-}
-
-suspend fun ParserTree.preProcess(context: PreProcessorContext): ParserTree {
-       return AsyncLexerTagEnvironment(
-               context,
-               PreProcessorTags.asTags,
-               PreProcessorUtils,
-               PreProcessorUtils,
-               PreProcessorUtils,
-               PreProcessorUtils,
-       ).processTree(this)
-}
-
-fun Exception.renderInBBCode(): ParserTree = listOf(
-       ParserTreeNode.Tag("error", null, listOf(
-               ParserTreeNode.Tag("b", null, listOf(ParserTreeNode.Text("${this::class.qualifiedName}: $message"))),
-               ParserTreeNode.LineBreak,
-               ParserTreeNode.Tag("ul", null,
-                       stackTraceToString().split(System.lineSeparator()).map {
-                               ParserTreeNode.Tag("li", null, listOf(ParserTreeNode.Text(it)))
-                       }
-               ),
-       )),
-)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_include.kt
deleted file mode 100644 (file)
index d7ef1cf..0000000
+++ /dev/null
@@ -1,143 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.JsonStorageCodec
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import io.ktor.util.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.json.*
-import java.security.MessageDigest
-import java.util.concurrent.ConcurrentHashMap
-import java.util.function.Consumer
-import javax.script.Compilable
-import javax.script.CompiledScript
-import javax.script.ScriptEngineManager
-import javax.script.SimpleBindings
-import kotlin.coroutines.*
-
-object PreProcessorTemplateLoader {
-       suspend fun loadTemplate(name: String): ParserTree {
-               val templateFile = StoragePath.templateDir / "$name.tpl"
-               val template = FileStorage.instance.readFile(templateFile) ?: return emptyList()
-               return ParserState.parseText(String(template))
-       }
-       
-       suspend fun runTemplateWith(name: String, args: Map<String, ParserTree>): ParserTree {
-               return loadTemplate(name).preProcess(PreProcessorContext(args))
-       }
-       
-       suspend fun runTemplateHere(name: String, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               return env.processTree(loadTemplate(name))
-       }
-}
-
-object PreProcessorScriptLoader {
-       private val scriptEngine = ThreadLocal.withInitial { ScriptEngineManager().getEngineByExtension("groovy") }
-       private val hasher = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
-       private val cache = ConcurrentHashMap<String, CompiledScript>()
-       
-       suspend fun loadFunction(name: String): CompiledScript? {
-               val scriptFile = StoragePath.scriptDir / "$name.groovy"
-               val script = FileStorage.instance.readFile(scriptFile) ?: return null
-               
-               val digest = hex(hasher.get().digest(script))
-               return withContext(Dispatchers.IO) {
-                       cache.computeIfAbsent(digest) { _ ->
-                               (scriptEngine.get() as Compilable).compile(String(script))
-                       }
-               }
-       }
-       
-       fun jsonToGroovy(json: JsonElement): Any? = when (json) {
-               JsonNull -> null
-               is JsonPrimitive -> if (json.isString)
-                       json.content
-               else
-                       json.booleanOrNull ?: json.intOrNull ?: json.double
-               
-               is JsonObject -> json.mapValues { (_, it) -> jsonToGroovy(it) }
-               is JsonArray -> json.map { jsonToGroovy(it) }
-       }
-       
-       fun groovyToJson(data: Any?): JsonElement = when (data) {
-               null -> JsonNull
-               is String -> JsonPrimitive(data)
-               is Number -> JsonPrimitive(data)
-               is Boolean -> JsonPrimitive(data)
-               is List<*> -> JsonArray(data.map { groovyToJson(it) })
-               is Set<*> -> JsonArray(data.map { groovyToJson(it) })
-               is Map<*, *> -> JsonObject(data.map { (k, v) -> k.toString() to groovyToJson(v) }.toMap())
-               else -> throw ClassCastException("Expected null, String, Number, Boolean, List, Set, or Map for converted data, got $data of type ${data::class.qualifiedName}")
-       }
-       
-       suspend fun runScriptInternal(script: CompiledScript, bind: Map<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): Any? {
-               return suspendCancellableCoroutine { continuation ->
-                       val bindings = SimpleBindings()
-                       bindings.putAll(bind)
-                       bindings["stdlib"] = PreProcessorScriptStdlib(env, continuation.context, continuation::resumeWithException)
-                       bindings["ctx"] = PreProcessorScriptVarContext { jsonToGroovy(env.context[it].toPreProcessJson()) }
-                       bindings["finish"] = Consumer<Any?>(continuation::resume)
-                       
-                       script.eval(bindings)
-               }
-       }
-       
-       private suspend fun runScriptWithBindings(scriptName: String, bind: Map<String, Any?>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
-               return try {
-                       val script = loadFunction(scriptName)!!
-                       val result = runScriptInternal(script, bind, env)
-                       return if (result is String)
-                               ParserState.parseText(result)
-                       else
-                               groovyToJson(result).toPreProcessTree()
-               } catch (ex: Exception) {
-                       errorHandler(ex)
-               }
-       }
-       
-       suspend fun runScriptSafe(scriptName: String, args: Map<String, ParserTree>, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
-               val groovyArgs = args.mapValuesTo(mutableMapOf()) { (_, it) -> jsonToGroovy(it.toPreProcessJson()) }
-               return runScriptWithBindings(scriptName, mapOf("args" to groovyArgs), env, errorHandler)
-       }
-       
-       suspend fun runScriptSafe(scriptName: String, input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, errorHandler: (Exception) -> ParserTree): ParserTree {
-               return runScriptWithBindings(scriptName, mapOf("text" to input.unparse()), env, errorHandler)
-       }
-}
-
-fun interface PreProcessorScriptVarContext {
-       operator fun get(name: String): Any?
-}
-
-class PreProcessorScriptStdlib(private val env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>, private val context: CoroutineContext, private val onError: (Throwable) -> Unit) {
-       fun jsonStringify(data: Any?): String {
-               return PreProcessorScriptLoader.groovyToJson(data).toString()
-       }
-       
-       fun jsonParse(json: String): Any? {
-               return PreProcessorScriptLoader.jsonToGroovy(JsonStorageCodec.parseToJsonElement(json))
-       }
-       
-       fun runScript(scriptName: String, args: Map<String, Any?>, useResult: Consumer<Any?>) {
-               suspend {
-                       val script = PreProcessorScriptLoader.loadFunction(scriptName)!!
-                       val argsMutable = if (args is MutableMap) args else args.toMutableMap()
-                       PreProcessorScriptLoader.runScriptInternal(script, argsMutable, env)
-               }.startCoroutine(Continuation(context) { result ->
-                       result.onSuccess(useResult::accept)
-                       result.onFailure(onError)
-               })
-       }
-       
-       fun runScript(scriptName: String, useResult: Consumer<Any?>) {
-               suspend {
-                       val script = PreProcessorScriptLoader.loadFunction(scriptName)!!
-                       PreProcessorScriptLoader.runScriptInternal(script, mutableMapOf(), env)
-               }.startCoroutine(Continuation(context) { result ->
-                       result.onSuccess(useResult::accept)
-                       result.onFailure(onError)
-               })
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_json.kt
deleted file mode 100644 (file)
index d688147..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.JsonStorageCodec
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import kotlinx.serialization.json.*
-
-fun JsonElement.toPreProcessTree(): ParserTree = when (this) {
-       JsonNull -> emptyList()
-       
-       is JsonPrimitive -> if (isString)
-               ParserState.parseText(content)
-       else listOf(ParserTreeNode.Text(content))
-       
-       is JsonArray -> map {
-               ParserTreeNode.Tag("item", null, it.toPreProcessTree())
-       }
-       
-       is JsonObject -> map {
-               ParserTreeNode.Tag("arg", it.key, it.value.toPreProcessTree())
-       }
-}
-
-fun ParserTreeNode.unparse(): String = when (this) {
-       is ParserTreeNode.Text -> text
-       ParserTreeNode.LineBreak -> "\n\n"
-       is ParserTreeNode.Tag -> buildString {
-               append("[")
-               append(tag)
-               param?.let {
-                       append("=")
-                       append(it)
-               }
-               append("]")
-               
-               append(subNodes.unparse())
-               
-               append("[/")
-               append(tag)
-               append("]")
-       }
-}
-
-fun ParserTree.unparse() = joinToString(separator = "") { it.unparse() }
-
-fun ParserTree.toPreProcessJson(): JsonElement {
-       val noBlanks = filterNot { it.isWhitespace() }
-       return if (noBlanks.all { it is ParserTreeNode.Tag && it isTag "item" && it.param == null })
-               JsonArray(asPreProcessorList().map { it.toPreProcessJson() })
-       else if (noBlanks.all { it is ParserTreeNode.Tag && it isTag "arg" && it.param != null })
-               JsonObject(asPreProcessorMap().mapValues { (_, it) -> it.toPreProcessJson() })
-       else if (noBlanks.size == 1)
-               when (val node = noBlanks.single()) {
-                       is ParserTreeNode.Text -> JsonPrimitive(node.text)
-                       ParserTreeNode.LineBreak -> JsonPrimitive("\n\n")
-                       is ParserTreeNode.Tag -> if (node isTag "val" && node.param == null) {
-                               val value = node.subNodes.treeToText()
-                               value.toBooleanStrictOrNull()?.let { JsonPrimitive(it) }
-                                       ?: value.toDoubleOrNull()?.let { JsonPrimitive(it) }
-                                       ?: JsonPrimitive(value)
-                       } else JsonPrimitive(node.unparse())
-               }
-       else JsonPrimitive(unparse())
-}
-
-object FactbookLoader {
-       suspend fun loadJsonData(lorePath: List<String>): JsonObject {
-               val jsonPath = lorePath.dropLast(1) + listOf("${lorePath.last()}.json")
-               val bytes = FileStorage.instance.readFile(StoragePath.jsonDocDir / jsonPath) ?: return JsonObject(emptyMap())
-               return JsonStorageCodec.parseToJsonElement(String(bytes)) as JsonObject
-       }
-       
-       suspend fun loadFactbookContext(lorePath: List<String>): Map<String, ParserTree> {
-               return loadJsonData(lorePath).mapValues { (_, it) -> it.toPreProcessTree() }
-       }
-       
-       suspend fun loadFactbook(lorePath: List<String>): ParserTree? {
-               val filePath = StoragePath.articleDir / lorePath
-               val bytes = FileStorage.instance.readFile(filePath) ?: return null
-               val inputTree = ParserState.parseText(String(bytes))
-               return inputTree.preProcess(PreProcessorContext(loadFactbookContext(lorePath) + PreProcessorContext.defaults(lorePath)))
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_preprocess_math.kt
deleted file mode 100644 (file)
index afc0f3c..0000000
+++ /dev/null
@@ -1,230 +0,0 @@
-package info.mechyrdia.lore
-
-import java.time.Instant
-import kotlin.math.*
-
-fun <T : Number> ParserTree.treeToNumberOrNull(convert: String.() -> T?) = treeToText().convert()
-
-fun ParserTree.treeToBooleanOrNull() = when (treeToText().lowercase()) {
-       "true" -> true
-       "false" -> false
-       else -> null
-}
-
-fun Number.numberToTree(): ParserTree = listOf(ParserTreeNode.Tag("val", null, "%f".format(toDouble()).textToTree()))
-
-fun Boolean.booleanToTree(): ParserTree = listOf(ParserTreeNode.Tag("val", null, toString().textToTree()))
-
-object PreProcessorMathOperators : PreProcessorFunctionLibrary("math") {
-       override val functions: Map<String, PreProcessorFunction> = mapOf(
-               "neg" to PreProcessorMathUnaryOperator(Double::unaryMinus),
-               "sin" to PreProcessorMathUnaryOperator(::sin),
-               "cos" to PreProcessorMathUnaryOperator(::cos),
-               "tan" to PreProcessorMathUnaryOperator(::tan),
-               "asin" to PreProcessorMathUnaryOperator(::asin),
-               "acos" to PreProcessorMathUnaryOperator(::acos),
-               "atan" to PreProcessorMathUnaryOperator(::atan),
-               "sqrt" to PreProcessorMathUnaryOperator(::sqrt),
-               "cbrt" to PreProcessorMathUnaryOperator(::cbrt),
-               "ceil" to PreProcessorMathUnaryOperator(::ceil),
-               "floor" to PreProcessorMathUnaryOperator(::floor),
-               "trunc" to PreProcessorMathUnaryOperator(::truncate),
-               "round" to PreProcessorMathUnaryOperator(::round),
-               
-               "add" to PreProcessorMathBinaryOperator(Double::plus),
-               "sub" to PreProcessorMathBinaryOperator(Double::minus),
-               "mul" to PreProcessorMathBinaryOperator(Double::times),
-               "div" to PreProcessorMathBinaryOperator(Double::div),
-               "mod" to PreProcessorMathBinaryOperator(Double::mod),
-               "pow" to PreProcessorMathBinaryOperator(Double::pow),
-               "log" to PreProcessorMathBinaryOperator(::log),
-               "min" to PreProcessorMathBinaryOperator(::min),
-               "max" to PreProcessorMathBinaryOperator(::max),
-               "hypot" to PreProcessorMathBinaryOperator(::hypot),
-               "atan2" to PreProcessorMathBinaryOperator(::atan2),
-               
-               "min" to PreProcessorMathVariadicOperator(List<Double>::min),
-               "max" to PreProcessorMathVariadicOperator(List<Double>::max),
-               "sum" to PreProcessorMathVariadicOperator(List<Double>::sum),
-               "prod" to PreProcessorMathVariadicOperator { it.fold(1.0, Double::times) },
-               "mean" to PreProcessorMathVariadicOperator { it.sum() / it.size.coerceAtLeast(1) },
-               
-               "eq" to PreProcessorMathPredicate { left, right -> left == right },
-               "lt" to PreProcessorMathPredicate { left, right -> left < right },
-               "gt" to PreProcessorMathPredicate { left, right -> left > right },
-               "ne" to PreProcessorMathPredicate { left, right -> left != right },
-               "le" to PreProcessorMathPredicate { left, right -> left <= right },
-               "ge" to PreProcessorMathPredicate { left, right -> left >= right },
-       )
-}
-
-fun interface PreProcessorMathUnaryOperator : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               val input = env.processTree(env.context["in"])
-               
-               return input.treeToNumberOrNull(String::toDoubleOrNull)
-                       ?.let { calculate(it) }
-                       ?.numberToTree()
-                       .formatError("Math operations require numerical inputs, got ${input.unparse()}")
-       }
-       
-       fun calculate(input: Double): Double
-}
-
-fun interface PreProcessorMathBinaryOperator : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               val leftValue = env.processTree(env.context["left"])
-               val rightValue = env.processTree(env.context["right"])
-               
-               val left = leftValue.treeToNumberOrNull(String::toDoubleOrNull)
-               val right = rightValue.treeToNumberOrNull(String::toDoubleOrNull)
-               
-               if (left == null || right == null)
-                       return formatErrorToParserTree("Math operations require numerical inputs, got ${leftValue.unparse()} and ${rightValue.unparse()}")
-               
-               return calculate(left, right).numberToTree()
-       }
-       
-       fun calculate(left: Double, right: Double): Double
-}
-
-fun interface PreProcessorMathVariadicOperator : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               val argsList = env.processTree(env.context["in"])
-               val args = argsList.asPreProcessorList().mapNotNull { it.treeToNumberOrNull(String::toDoubleOrNull) }
-               
-               if (args.isEmpty() && argsList.isNotEmpty())
-                       return formatErrorToParserTree("Math operations require numerical inputs, got ${argsList.unparse()}")
-               
-               return calculate(args).numberToTree()
-       }
-       
-       fun calculate(args: List<Double>): Double
-}
-
-fun interface PreProcessorMathPredicate : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               val leftValue = env.processTree(env.context["left"])
-               val rightValue = env.processTree(env.context["right"])
-               
-               val left = leftValue.treeToNumberOrNull(String::toDoubleOrNull)
-               val right = rightValue.treeToNumberOrNull(String::toDoubleOrNull)
-               
-               if (left == null || right == null)
-                       return formatErrorToParserTree("Math operations require numerical inputs, got ${leftValue.unparse()} and ${rightValue.unparse()}")
-               
-               return calculate(left, right).booleanToTree()
-       }
-       
-       fun calculate(left: Double, right: Double): Boolean
-}
-
-fun interface PreProcessorLogicBinaryOperator : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               val leftValue = env.processTree(env.context["left"])
-               val rightValue = env.processTree(env.context["right"])
-               
-               val left = leftValue.treeToBooleanOrNull()
-               val right = rightValue.treeToBooleanOrNull()
-               
-               if (left == null || right == null)
-                       return formatErrorToParserTree("Logical operations require boolean inputs, got ${leftValue.unparse()} and ${rightValue.unparse()}")
-               
-               return calculate(left, right).booleanToTree()
-       }
-       
-       fun calculate(left: Boolean, right: Boolean): Boolean
-}
-
-fun interface PreProcessorLogicOperator : PreProcessorFunction {
-       override suspend fun execute(env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               val argsList = env.processTree(env.context["in"])
-               val args = argsList.asPreProcessorList().mapNotNull { it.treeToBooleanOrNull() }
-               
-               if (args.isEmpty() && argsList.isNotEmpty())
-                       return formatErrorToParserTree("Logical operations require boolean inputs, got ${argsList.unparse()}")
-               
-               return calculate(args).booleanToTree()
-       }
-       
-       fun calculate(inputs: List<Boolean>): Boolean
-       
-       companion object : PreProcessorFunctionLibrary("logic") {
-               override val functions: Map<String, PreProcessorFunction> = mapOf(
-                       "not" to PreProcessorFunction { env ->
-                               val input = env.processTree(env.context["in"])
-                               
-                               input
-                                       .treeToBooleanOrNull()
-                                       ?.let { "${!it}".textToTree() }
-                                       .formatError("Logical operations require boolean inputs, got ${input.unparse()}")
-                       },
-                       
-                       "and" to PreProcessorLogicBinaryOperator { left, right -> left && right },
-                       "or" to PreProcessorLogicBinaryOperator { left, right -> left || right },
-                       "xor" to PreProcessorLogicBinaryOperator { left, right -> left xor right },
-                       "nand" to PreProcessorLogicBinaryOperator { left, right -> !(left && right) },
-                       "nor" to PreProcessorLogicBinaryOperator { left, right -> !(left || right) },
-                       "xnor" to PreProcessorLogicBinaryOperator { left, right -> !(left xor right) },
-                       "implies" to PreProcessorLogicBinaryOperator { left, right -> left || !right },
-                       
-                       "all" to PreProcessorLogicOperator { inputs -> inputs.all { it } },
-                       "any" to PreProcessorLogicOperator { inputs -> inputs.any { it } },
-                       "not_all" to PreProcessorLogicOperator { inputs -> inputs.any { !it } },
-                       "none" to PreProcessorLogicOperator { inputs -> inputs.none { it } },
-                       "count" to PreProcessorFunction { env ->
-                               val argsList = env.processTree(env.context["in"])
-                               val args = argsList.asPreProcessorList().mapNotNull { it.treeToBooleanOrNull() }
-                               
-                               if (args.isEmpty() && argsList.isNotEmpty())
-                                       formatErrorToParserTree("Logical operations require boolean inputs, got ${argsList.unparse()}")
-                               else
-                                       args.count { it }.numberToTree()
-                       },
-               )
-       }
-}
-
-fun interface PreProcessorFormatter : PreProcessorFilter {
-       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               return calculate(input.treeToText())
-       }
-       
-       fun calculate(input: String): ParserTree
-       
-       companion object : PreProcessorFilterLibrary("format") {
-               override val filters: Map<String, PreProcessorFilter> = mapOf(
-                       "iso_instant" to PreProcessorFormatter {
-                               it.toLongOrNull()
-                                       ?.let { long ->
-                                               Instant.ofEpochMilli(long).toString().textToTree()
-                                       }.formatError("ISO Instant values must be formatted as base-10 long values, got $it")
-                       },
-                       "local_instant" to PreProcessorFormatter {
-                               it.toLongOrNull()
-                                       ?.let { long ->
-                                               listOf(ParserTreeNode.Tag("moment", null, listOf(ParserTreeNode.Text(long.toString()))))
-                                       }.formatError("ISO Instant values must be formatted as base-10 long values, got $it")
-                       },
-               )
-       }
-}
-
-fun interface PreProcessorInputTest : PreProcessorFilter {
-       override suspend fun execute(input: ParserTree, env: AsyncLexerTagEnvironment<PreProcessorContext, PreProcessorSubject>): ParserTree {
-               return calculate(input).booleanToTree()
-       }
-       
-       fun calculate(input: ParserTree): Boolean
-       
-       companion object : PreProcessorFilterLibrary("test") {
-               override val filters: Map<String, PreProcessorFilter> = mapOf(
-                       "null" to PreProcessorInputTest { it.isNull() },
-                       "empty" to PreProcessorInputTest { it.isEmpty() },
-                       "blank" to PreProcessorInputTest { it.isWhitespace() },
-                       "not_null" to PreProcessorInputTest { !it.isNull() },
-                       "not_empty" to PreProcessorInputTest { it.isNotEmpty() },
-                       "not_blank" to PreProcessorInputTest { !it.isWhitespace() },
-               )
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt
deleted file mode 100644 (file)
index 0356bca..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-package info.mechyrdia.lore
-
-import io.ktor.util.*
-import kotlinx.html.*
-import java.time.Instant
-
-fun String.toRawLink() = substringBeforeLast('#').sanitizeLink().toInternalUrl() + "?format=raw"
-
-fun processRawInternalLink(param: String?): Map<String, String> = param
-       ?.toRawLink()
-       ?.let { mapOf("href" to it) }
-       .orEmpty()
-
-fun processRawLanguage(param: String?): Map<String, String> = mapOf("data-lang" to (param ?: "foreign"))
-
-private class HtmlDataFormatTag(val dataFormat: String) : HtmlLexerTag {
-       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
-               val content = HtmlLexerProcessor.combineInline(env, subNodes)
-               
-               return {
-                       span {
-                               attributes["data-format"] = dataFormat
-                               +content
-                       }
-               }
-       }
-}
-
-private class HtmlNotSupportedInRawViewTag(val message: String) : HtmlLexerTag {
-       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
-               return { p { +message } }
-       }
-}
-
-enum class RawFactbookFormattingTag(val type: HtmlLexerTag) {
-       B(HtmlDataFormatTag("b")),
-       I(HtmlDataFormatTag("i")),
-       U(HtmlDataFormatTag("u")),
-       S(HtmlDataFormatTag("s")),
-       IPA(HtmlDataFormatTag("ipa")),
-       CODE(HtmlDataFormatTag("code")),
-       CODE_BLOCK(HtmlLexerTag { env, _, subNodes ->
-               val content = HtmlLexerProcessor.combineInline(env, subNodes)
-               ({
-                       div {
-                               attributes["data-format"] = "code"
-                               pre { +content }
-                       }
-               })
-       }),
-       ERROR(HtmlLexerTag { env, _, subNodes ->
-               val content = HtmlLexerProcessor.combineInline(env, subNodes)
-               ({
-                       div {
-                               attributes["data-format"] = "error"
-                               +content
-                       }
-               })
-       }),
-       H1(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h1.toTagCreator())),
-       H2(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h2.toTagCreator())),
-       H3(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h3.toTagCreator())),
-       H4(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h4.toTagCreator())),
-       H5(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h5.toTagCreator())),
-       H6(HtmlTagLexerTag(tagMode = HtmlTagMode.BLOCK, tagCreator = TagConsumer<*>::h6.toTagCreator())),
-       ALIGN(HtmlLexerTag { env, param, subNodes ->
-               val alignments = setOf("left", "center", "right", "justify")
-               val alignment = param?.lowercase()?.takeIf { it in alignments }
-               val content = HtmlLexerProcessor.combineBlock(env, subNodes)
-               
-               ({
-                       div {
-                               alignment?.let { attributes["data-align"] = it }
-                               +content
-                       }
-               })
-       }),
-       ASIDE(HtmlLexerTag { env, param, subNodes ->
-               val alignments = setOf("left", "right")
-               val alignment = param?.lowercase()?.takeIf { it in alignments }
-               val content = HtmlLexerProcessor.combineBlock(env, subNodes)
-               
-               ({
-                       div {
-                               alignment?.let { attributes["data-aside"] = it }
-                               +content
-                       }
-               })
-       }),
-       IMAGE(HtmlTextBodyLexerTag { _, param, content ->
-               val url = content.sanitizeLink()
-               val (width, height) = getSizeParam(param)
-               val styleValue = getRawImageSizeStyleValue(width, height)
-               
-               ({
-                       img(src = "/assets/images/$url") {
-                               width?.let { attributes["data-width"] = "$it" }
-                               height?.let { attributes["data-height"] = "$it" }
-                               style = styleValue
-                       }
-               })
-       }),
-       MODEL(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive 3D model views")),
-       QUIZ(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive quizzes")),
-       MOMENT(HtmlTextBodyLexerTag { _, _, content ->
-               val epochMilli = content.toLongOrNull()
-               if (epochMilli == null)
-                       ({ +content })
-               else
-                       ({ +Instant.ofEpochMilli(epochMilli).toString() })
-       }),
-       LINK(HtmlTagLexerTag(attributes = ::processRawInternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
-       REDIRECT(HtmlTextBodyLexerTag { _, _, content ->
-               val url = content.toRawLink()
-               
-               ({
-                       a(href = url) { +"Manual page redirect" }
-               })
-       }),
-       LANG(HtmlTagLexerTag(attributes = ::processRawLanguage, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::span.toTagCreator())),
-       
-       ALPHABET(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive constructed script previews")),
-       VOCAB(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive constructed language dictionaries")),
-       ;
-       
-       companion object {
-               val asTags = FactbookFormattingTag.asTags + LexerTags(entries.associate { it.name to it.type })
-       }
-}
-
-fun ParserTree.toRawHtml(): TagConsumer<*>.() -> Any? {
-       return LexerTagEnvironment(
-               Unit,
-               RawFactbookFormattingTag.asTags,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-               HtmlLexerProcessor,
-       ).processTree(this)
-}
-
-fun getRawImageSizeStyleValue(width: Int?, height: Int?) = width?.let { "width:${it * 0.25}px;" }.orEmpty() + height?.let { "height:${it * 0.25}px;" }.orEmpty()
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tree.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tree.kt
deleted file mode 100644 (file)
index bd2187c..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-package info.mechyrdia.lore
-
-inline fun <TSubject> String.parseAs(converter: ParserTree.() -> TSubject) = ParserState.parseText(this).converter()
-
-inline fun <TContext> String.parseAs(context: TContext, converter: ParserTree.(TContext) -> Unit) = ParserState.parseText(this).converter(context)
-
-sealed class ParserTreeNode {
-       data class Text(val text: String) : ParserTreeNode()
-       
-       data object LineBreak : ParserTreeNode()
-       
-       data class Tag(val tag: String, val param: String?, val subNodes: ParserTree) : ParserTreeNode()
-}
-
-infix fun ParserTreeNode.Tag.isTag(test: String) = tag.equals(test, ignoreCase = true)
-infix fun ParserTreeNode.Tag.isTag(test: Collection<String>) = test.any { tag.equals(it, ignoreCase = true) }
-
-infix fun ParserTreeNode.Tag.isNotTag(test: String) = !tag.equals(test, ignoreCase = true)
-infix fun ParserTreeNode.Tag.isNotTag(test: Collection<String>) = test.none { tag.equals(it, ignoreCase = true) }
-
-typealias ParserTree = List<ParserTreeNode>
-
-sealed class ParserTreeBuilderState {
-       private val nodes = mutableListOf<ParserTreeNode>()
-       private val currentString = StringBuilder()
-       
-       fun text(text: String) {
-               currentString.append(text)
-       }
-       
-       private fun endText() {
-               if (currentString.isEmpty()) return
-               nodes.add(ParserTreeNode.Text(currentString.toString().replace('\n', ' ')))
-               currentString.clear()
-       }
-       
-       fun newLine() {
-               endText()
-               nodes.add(ParserTreeNode.LineBreak)
-       }
-       
-       fun endDoc(): ParserTree {
-               endText()
-               return nodes
-       }
-       
-       fun beginTag(tag: String, param: String?): TreeTag {
-               endText()
-               return TreeTag(this, tag, param)
-       }
-       
-       open fun canEndTag(endTag: String): TreeTag? = null
-       
-       protected fun doneTag(tag: ParserTreeNode.Tag): ParserTreeBuilderState {
-               nodes.add(tag)
-               return this
-       }
-       
-       class TreeRoot : ParserTreeBuilderState()
-       
-       class TreeTag(
-               private val parent: ParserTreeBuilderState,
-               private val tag: String,
-               private val param: String? = null
-       ) : ParserTreeBuilderState() {
-               override fun canEndTag(endTag: String): TreeTag? {
-                       return if (tag.equals(endTag, ignoreCase = true)) this else null
-               }
-               
-               fun endTag(): ParserTreeBuilderState {
-                       return parent.doneTag(ParserTreeNode.Tag(tag, param, endDoc()))
-               }
-       }
-}
-
-sealed class ParserState(
-       protected val builder: ParserTreeBuilderState
-) {
-       abstract fun processCharacter(char: Char): ParserState
-       open fun processEndOfText(): ParserTree = builder.unwind()
-       
-       class Initial : ParserState(ParserTreeBuilderState.TreeRoot()) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == '[')
-                               OpenTag("", builder)
-                       else
-                               PlainText("$char", builder)
-               }
-               
-               override fun processEndOfText(): ParserTree {
-                       return emptyList()
-               }
-       }
-       
-       class PlainText(private val text: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == '[') {
-                               builder.text(text)
-                               OpenTag("", builder)
-                       } else if (char == '\n' && text.endsWith('\n')) {
-                               builder.text(text.removeSuffix("\n"))
-                               builder.newLine()
-                               
-                               PlainText("", builder)
-                       } else PlainText("$text$char", builder)
-               }
-               
-               override fun processEndOfText(): ParserTree {
-                       builder.text(text)
-                       return super.processEndOfText()
-               }
-       }
-       
-       class NoFormatText(private val text: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == '\n' && text.endsWith('\n')) {
-                               builder.text(text.removeSuffix("\n"))
-                               builder.newLine()
-                               
-                               NoFormatText("", builder)
-                       } else {
-                               val newText = "$text$char"
-                               val endTag = "[/$NO_FORMAT_TAG]"
-                               if (newText.endsWith(endTag, ignoreCase = true)) {
-                                       builder.text(newText.substring(0, newText.length - endTag.length))
-                                       PlainText("", builder)
-                               } else NoFormatText(newText, builder)
-                       }
-               }
-               
-               override fun processEndOfText(): ParserTree {
-                       builder.text(text)
-                       return super.processEndOfText()
-               }
-       }
-       
-       class OpenTag(private val tagName: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == ']') {
-                               if (tagName.equals(NO_FORMAT_TAG, ignoreCase = true))
-                                       NoFormatText("", builder)
-                               else
-                                       PlainText("", builder.beginTag(tagName, null))
-                       } else if (char == '=')
-                               TagParam(tagName, "", builder)
-                       else if (char == '/' && tagName.isEmpty())
-                               CloseTag("", builder)
-                       else
-                               OpenTag("$tagName$char", builder)
-               }
-               
-               override fun processEndOfText(): ParserTree {
-                       builder.text("[$tagName")
-                       return super.processEndOfText()
-               }
-       }
-       
-       class TagParam(private val tagName: String, private val tagParam: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == ']')
-                               PlainText("", builder.beginTag(tagName, tagParam))
-                       else
-                               TagParam(tagName, "$tagParam$char", builder)
-               }
-               
-               override fun processEndOfText(): ParserTree {
-                       builder.text("[$tagName=$tagParam")
-                       return super.processEndOfText()
-               }
-       }
-       
-       class CloseTag(private val tagName: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == ']')
-                               builder.canEndTag(tagName)?.endTag()?.let {
-                                       PlainText("", it)
-                               } ?: PlainText("[/$tagName]", builder)
-                       else CloseTag("$tagName$char", builder)
-               }
-               
-               override fun processEndOfText(): ParserTree {
-                       builder.text("[/$tagName")
-                       return super.processEndOfText()
-               }
-       }
-       
-       companion object {
-               const val NO_FORMAT_TAG = "noformat"
-               
-               private fun ParserTreeBuilderState.unwind(): ParserTree {
-                       return when (this) {
-                               is ParserTreeBuilderState.TreeRoot -> endDoc()
-                               is ParserTreeBuilderState.TreeTag -> endTag().unwind()
-                       }
-               }
-               
-               fun parseText(text: String): ParserTree {
-                       val fixedText = text.replace("\r\n", "\n").replace('\r', '\n')
-                       return fixedText.fold<ParserState>(Initial()) { state, char ->
-                               state.processCharacter(char)
-                       }.processEndOfText()
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_utils.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_utils.kt
deleted file mode 100644 (file)
index 29742b6..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-package info.mechyrdia.lore
-
-fun <T> List<T>.splitOn(predicate: (T) -> Boolean): List<List<T>> {
-       val whole = mutableListOf<List<T>>()
-       val current = mutableListOf<T>()
-       
-       for (item in this) {
-               if (predicate(item)) {
-                       if (current.isNotEmpty()) {
-                               whole.add(current.toList())
-                               current.clear()
-                       }
-               } else
-                       current.add(item)
-       }
-       
-       if (current.isNotEmpty())
-               whole.add(current.toList())
-       
-       return whole.toList()
-}
-
-fun <T> List<T>.splitBefore(predicate: (T) -> Boolean): List<List<T>> {
-       val whole = mutableListOf<List<T>>()
-       val current = mutableListOf<T>()
-       
-       for (item in this) {
-               if (predicate(item)) {
-                       whole.add(current.toList())
-                       current.clear()
-               }
-               
-               current.add(item)
-       }
-       
-       whole.add(current.toList())
-       return whole.toList()
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_bar.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_bar.kt
deleted file mode 100644 (file)
index 06b0d15..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.NationData
-import kotlinx.html.*
-
-abstract class Sidebar {
-       protected abstract fun TagConsumer<*>.display()
-       fun displayIn(aside: ASIDE) = aside.consumer.display()
-}
-
-data class PageNavSidebar(val contents: List<NavItem>) : Sidebar() {
-       override fun TagConsumer<*>.display() {
-               div(classes = "list") {
-                       for (content in contents) {
-                               div(classes = "item") {
-                                       content.displayIn(this)
-                               }
-                       }
-               }
-       }
-}
-
-data class NationProfileSidebar(val nationData: NationData) : Sidebar() {
-       override fun TagConsumer<*>.display() {
-               img(src = nationData.flag, alt = "Flag of ${nationData.name}", classes = "flag-icon")
-               p {
-                       style = "text-align:center"
-                       a(href = "https://www.nationstates.net/nation=${nationData.id}") {
-                               +nationData.name
-                       }
-               }
-       }
-}
-
-data class QuoteOriginSidebar(val author: String, val fullPortrait: String, val fullLink: String) : Sidebar() {
-       override fun TagConsumer<*>.display() {
-               img(src = fullPortrait, alt = "Portrait of $author")
-               p {
-                       style = "text-align:center"
-                       a(href = fullLink) {
-                               +author
-                       }
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_map.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_map.kt
deleted file mode 100644 (file)
index 141257b..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.StoragePath
-import io.ktor.server.application.*
-
-fun ApplicationCall.galaxyMapPage(): StoragePath {
-       val themeName = when (pageTheme) {
-               PageTheme.SYSTEM -> "system"
-               PageTheme.LIGHT -> "light"
-               PageTheme.DARK -> "dark"
-       }
-       
-       return StoragePath.assetDir / listOf("map", "index-$themeName.html")
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt
deleted file mode 100644 (file)
index 44f7890..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.JsonFileCodec
-import info.mechyrdia.OwnerNationId
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import info.mechyrdia.data.currentNation
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.createCsrfToken
-import info.mechyrdia.route.href
-import io.ktor.server.application.*
-import kotlinx.html.*
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.builtins.ListSerializer
-import kotlin.collections.component1
-import kotlin.collections.component2
-import kotlin.collections.set
-
-@Serializable
-private data class ExternalLink(
-       val url: String,
-       val text: String
-)
-
-suspend fun loadExternalLinks(): List<NavItem> {
-       val extraLinksFile = StoragePath.Root / "externalLinks.json"
-       val extraLinksJson = String(FileStorage.instance.readFile(extraLinksFile)!!)
-       val extraLinks = JsonFileCodec.decodeFromString(ListSerializer(ExternalLink.serializer()), extraLinksJson)
-       return if (extraLinks.isEmpty())
-               emptyList()
-       else (listOf(NavHead("See Also")) + extraLinks.map { NavLink(it.url, it.text, textIsHtml = true) })
-}
-
-suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
-       NavLink(href(Root()), "Lore Intro"),
-       NavLink(href(Root.LorePage(emptyList())), TOC_TITLE),
-) + path?.let { pathParts ->
-       pathParts.indices.drop(1).map { i ->
-               val subPath = pathParts.take(i)
-               NavLink(href(Root.LorePage(subPath)), (StoragePath.articleDir / subPath).toFriendlyPageTitle())
-       }
-}.orEmpty() + (currentNation()?.let { data ->
-       listOf(
-               NavHead(data.name),
-               NavLink(href(Root.User()), "Your User Page"),
-               NavLink("https://www.nationstates.net/nation=${data.id}", "Your NationStates Page"),
-               NavLink.withCsrf(href(Root.Auth.LogoutPost()), "Log Out"),
-       )
-} ?: listOf(
-       NavHead("Log In"),
-       NavLink(href(Root.Auth.LoginPage()), "Log In with NationStates"),
-)) + listOf(
-       NavLink(href(Root.ClientPreferences()), "Client Preferences"),
-       NavHead("Useful Links"),
-       NavLink(href(Root.Comments.HelpPage()), "Commenting Help"),
-       NavLink(href(Root.Comments.RecentPage()), "Recent Comments"),
-) + loadExternalLinks() + (if (currentNation()?.id == OwnerNationId)
-       listOf(
-               NavHead("Administration"),
-               NavLink(href(Root.Admin.Vfs.View(emptyList())), "View VFS"),
-       )
-else emptyList())
-
-sealed class NavItem {
-       protected abstract fun DIV.display()
-       fun displayIn(div: DIV) = div.display()
-}
-
-data class NavHead(val label: String) : NavItem() {
-       override fun DIV.display() {
-               span {
-                       style = "font-variant:small-caps;text-decoration:underline"
-                       +label
-               }
-       }
-}
-
-data class NavLink(
-       val to: String,
-       val text: String,
-       val textIsHtml: Boolean = false,
-       val aClasses: String? = null,
-       val linkAttributes: Map<String, String> = emptyMap()
-) : NavItem() {
-       override fun DIV.display() {
-               a(href = to, classes = aClasses) {
-                       for ((attrName, attrValue) in linkAttributes)
-                               attributes[attrName] = attrValue
-                       
-                       if (textIsHtml)
-                               unsafe { raw(text) }
-                       else
-                               +text
-               }
-       }
-       
-       companion object {
-               context(ApplicationCall)
-               fun withCsrf(to: String, text: String, textIsHtml: Boolean = false, aClasses: String? = null, extraAttributes: Map<String, String> = emptyMap): NavLink {
-                       return NavLink(
-                               to = to,
-                               text = text,
-                               textIsHtml = textIsHtml,
-                               aClasses = aClasses,
-                               linkAttributes = extraAttributes + mapOf(
-                                       "data-method" to "post",
-                                       "data-csrf-token" to createCsrfToken(to)
-                               )
-                       )
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_og.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_og.kt
deleted file mode 100644 (file)
index 94374d7..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-package info.mechyrdia.lore
-
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import kotlinx.html.HEAD
-import kotlinx.html.meta
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class OpenGraphData(
-       val desc: String,
-       val image: String
-)
-
-fun HEAD.ogProperty(property: String, content: String) {
-       meta {
-               attributes["property"] = "og:$property"
-               attributes["content"] = content
-       }
-}
-
-context(ApplicationCall)
-fun HEAD.renderOgData(title: String, data: OpenGraphData) {
-       meta(name = "description", content = data.desc)
-       
-       ogProperty("title", title)
-       ogProperty("type", "website")
-       ogProperty("description", data.desc)
-       ogProperty("image", data.image)
-       ogProperty("url", "https://mechyrdia.info/${request.path().removePrefix("/")}")
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt
deleted file mode 100644 (file)
index 3618db1..0000000
+++ /dev/null
@@ -1,225 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.route.ErrorMessageAttributeKey
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.util.*
-import kotlinx.html.*
-import java.time.Instant
-
-private val preloadFonts = listOf(
-       "DejaVuSans-Bold.woff",
-       "DejaVuSans-BoldOblique.woff",
-       "DejaVuSans-Oblique.woff",
-       "DejaVuSans.woff",
-       "JetBrainsMono-ExtraBold.woff",
-       "JetBrainsMono-ExtraBoldItalic.woff",
-       "JetBrainsMono-Medium.woff",
-       "JetBrainsMono-MediumItalic.woff",
-       "Oxanium-Bold.woff",
-       "Oxanium-ExtraBold.woff",
-       "Oxanium-Regular.woff",
-       "Oxanium-SemiBold.woff",
-       "tylan-language-alphabet-3.woff",
-       "thedish-language-alphabet.woff",
-       "pokhval-language-alphabet.woff",
-       "kishari-language-alphabet.woff",
-)
-
-private val preloadImages = listOf(
-       "external-link-dark.png",
-       "external-link.png",
-       "icon.png",
-)
-
-context(ApplicationCall)
-private fun HEAD.initialHead(pageTitle: String, ogData: OpenGraphData?) {
-       meta(charset = "utf-8")
-       meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
-       
-       meta(name = "theme-color", content = "#FFCC33")
-       
-       ogData?.let { data ->
-               renderOgData(pageTitle, data)
-       }
-       
-       link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.png")
-       
-       title {
-               +pageTitle
-       }
-}
-
-fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sidebar: Sidebar? = null, ogData: OpenGraphData? = null, content: MAIN.() -> Unit): HTML.() -> Unit {
-       return {
-               pageTheme.attributeValue?.let { attributes["data-theme"] = it }
-               
-               lang = "en"
-               
-               head {
-                       initialHead(pageTitle, ogData)
-                       
-                       for (font in preloadFonts)
-                               link(
-                                       rel = "preload",
-                                       href = "/static/font/$font",
-                                       type = "font/woff"
-                               ) {
-                                       attributes["as"] = "font"
-                               }
-                       
-                       for (image in preloadImages)
-                               link(
-                                       rel = "preload",
-                                       href = "/static/images/$image",
-                                       type = "image/png"
-                               ) {
-                                       attributes["as"] = "image"
-                               }
-                       
-                       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 {
-                       div { id = "bg" }
-                       
-                       navBar?.let { nb ->
-                               nav(classes = "desktop") {
-                                       div(classes = "list") {
-                                               for (ni in nb) {
-                                                       div(classes = "item") {
-                                                               ni.displayIn(this)
-                                                       }
-                                               }
-                                       }
-                               }
-                       }
-                       
-                       sidebar?.let {
-                               aside(classes = "desktop") {
-                                       it.displayIn(this)
-                               }
-                       }
-                       
-                       main {
-                               sidebar?.let {
-                                       aside(classes = "mobile") {
-                                               it.displayIn(this)
-                                       }
-                               }
-                               
-                               content()
-                               
-                               navBar?.let { nb ->
-                                       nav(classes = "mobile") {
-                                               div(classes = "list") {
-                                                       for (ni in nb) {
-                                                               div(classes = "item") {
-                                                                       ni.displayIn(this)
-                                                               }
-                                                       }
-                                               }
-                                       }
-                               }
-                       }
-                       
-                       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 = if (paramsWithoutError.isEmpty())
-                                               ""
-                                       else "?${paramsWithoutError.formUrlEncode()}"
-                                       attributes["data-redirect-url"] = "${request.path()}$newQueryString"
-                                       
-                                       div(classes = "bg")
-                                       div(classes = "msg") {
-                                               p { +errorMessage }
-                                               p { +"Click to close this popup" }
-                                       }
-                               }
-                       }
-               }
-       }
-}
-
-fun ApplicationCall.rawPage(pageTitle: String, ogData: OpenGraphData? = null, content: BODY.() -> Unit): HTML.() -> Unit {
-       return {
-               lang = "en"
-               
-               head {
-                       initialHead(pageTitle, ogData)
-                       
-                       link(rel = "stylesheet", type = "text/css", href = "/static/raw.css")
-               }
-               body {
-                       content()
-               }
-       }
-}
-
-private val adminPreloadFonts = listOf(
-       "JetBrainsMono-ExtraBold.woff",
-       "JetBrainsMono-ExtraBoldItalic.woff",
-       "JetBrainsMono-Medium.woff",
-       "JetBrainsMono-MediumItalic.woff",
-)
-
-fun ApplicationCall.adminPage(pageTitle: String, content: BODY.() -> Unit): HTML.() -> Unit {
-       return {
-               lang = "en"
-               
-               head {
-                       initialHead(pageTitle, null)
-                       
-                       for (font in adminPreloadFonts)
-                               link(
-                                       rel = "preload",
-                                       href = "/static/font/$font",
-                                       type = "font/woff"
-                               ) {
-                                       attributes["as"] = "font"
-                               }
-                       
-                       link(rel = "stylesheet", type = "text/css", href = "/static/admin.css")
-                       
-                       script(src = "/static/admin.js") {}
-               }
-               body {
-                       content()
-               }
-       }
-}
-
-fun FlowOrPhrasingContent.dateTime(instant: Instant) {
-       span(classes = "moment") {
-               style = "display:none"
-               +"${instant.toEpochMilli()}"
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt
deleted file mode 100644 (file)
index 3d4bded..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.OwnerNationId
-import info.mechyrdia.data.currentNation
-import info.mechyrdia.route.CsrfProtectedResourcePayload
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.href
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import kotlinx.html.*
-
-suspend fun ApplicationCall.errorPage(title: String, body: FlowContent.() -> Unit): HTML.() -> Unit {
-       return if (request.queryParameters["format"] == "raw")
-               rawPage(title) {
-                       h1 { +title }
-                       body()
-               }
-       else if (request.uri.startsWith("/admin/vfs") && currentNation()?.id == OwnerNationId)
-               adminPage(title) {
-                       div(classes = "message") {
-                               h1 { +title }
-                               body()
-                       }
-               }
-       else
-               page(title, standardNavBar()) {
-                       section {
-                               h1 { +title }
-                               body()
-                       }
-               }
-}
-
-suspend fun ApplicationCall.error400(): HTML.() -> Unit = errorPage("400 Bad Request") {
-       p { +"The request your browser sent was improperly formatted." }
-}
-
-suspend fun ApplicationCall.error403(): HTML.() -> Unit = errorPage("403 Forbidden") {
-       p { +"You are not allowed to do that." }
-}
-
-suspend fun ApplicationCall.error403PageExpired(payload: CsrfProtectedResourcePayload): HTML.() -> Unit = errorPage("Page Expired") {
-       with(payload) { displayRetryData() }
-       p {
-               +"The page you were on has expired."
-               request.header(HttpHeaders.Referrer)?.let { referrer ->
-                       +" You can "
-                       a(href = referrer) { +"return to the previous page" }
-                       +" and retry your action."
-               }
-       }
-}
-
-suspend fun ApplicationCall.error404(): HTML.() -> Unit = errorPage("404 Not Found") {
-       p {
-               +"Unfortunately, we could not find what you were looking for. Would you like to "
-               a(href = href(Root())) { +"return to the index page" }
-               +"?"
-       }
-}
-
-suspend fun ApplicationCall.error409(): HTML.() -> Unit = errorPage("409 Conflict") {
-       p {
-               +"Your attempted action conflicts with an existing resource."
-               request.header(HttpHeaders.Referrer)?.let { referrer ->
-                       +" You can "
-                       a(href = referrer) { +"return to the previous page" }
-                       +" and retry your action."
-               }
-       }
-}
-
-suspend fun ApplicationCall.error500(): HTML.() -> Unit = errorPage("500 Internal Error") {
-       p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt
deleted file mode 100644 (file)
index 56098fe..0000000
+++ /dev/null
@@ -1,219 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.JsonFileCodec
-import info.mechyrdia.data.*
-import info.mechyrdia.route.KeyedEnumSerializer
-import info.mechyrdia.route.Root
-import info.mechyrdia.route.href
-import io.ktor.server.application.*
-import kotlinx.coroutines.async
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.flow.toList
-import kotlinx.html.*
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class IntroMetaData(
-       val title: String,
-       val desc: String,
-       val image: String
-) {
-       val ogData: OpenGraphData
-               get() = OpenGraphData(desc, image)
-}
-
-suspend fun ApplicationCall.loreIntroPage(): HTML.() -> Unit {
-       val metaJson = String(FileStorage.instance.readFile(StoragePath.Root / "introMeta.json")!!)
-       val metaData = JsonFileCodec.decodeFromString(IntroMetaData.serializer(), metaJson)
-       
-       val html = String(FileStorage.instance.readFile(StoragePath.Root / "intro.html")!!)
-       
-       return page(metaData.title, standardNavBar(), null, metaData.ogData) {
-               section {
-                       a { id = "page-top" }
-                       unsafe { raw(html) }
-               }
-       }
-}
-
-private val Tag.breadCrumbArrow: Unit
-       get() {
-               +Entities.nbsp
-               +Entities.gt
-               +Entities.nbsp
-       }
-
-context(ApplicationCall)
-private fun FlowContent.breadCrumbs(links: List<Pair<Root.LorePage, String>>) = p {
-       links.joinToHtml(Tag::breadCrumbArrow) { (url, text) ->
-               a(href = href(url)) { +text }
-       }
-}
-
-const val TOC_TITLE = "Table of Contents"
-
-@Serializable(with = LoreArticleFormatSerializer::class)
-enum class LoreArticleFormat(val format: String? = null) {
-       HTML(null),
-       RAW_HTML("raw"),
-       ;
-}
-
-object LoreArticleFormatSerializer : KeyedEnumSerializer<LoreArticleFormat>(LoreArticleFormat.entries, LoreArticleFormat::format)
-
-suspend fun ApplicationCall.loreRawArticlePage(pagePath: List<String>): HTML.() -> Unit {
-       val pageFile = StoragePath.articleDir / pagePath
-       val pageNode = pageFile.toArticleNode()
-       
-       val parentPaths = if (pagePath.isEmpty())
-               emptyList()
-       else {
-               val pathDirs = pagePath.drop(1)
-               listOf(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML) to TOC_TITLE) + pathDirs.indices.map { i ->
-                       val prefixPath = pathDirs.take(i)
-                       Root.LorePage(prefixPath, LoreArticleFormat.RAW_HTML) to (StoragePath.articleDir / prefixPath).toFriendlyPageTitle()
-               }
-       }
-       
-       val pageType = FileStorage.instance.getType(pageFile)
-       val isValid = pageType != null && pageFile.isViewable
-       
-       if (isValid) {
-               if (pageType == StoredFileType.DIRECTORY) {
-                       return rawPage(pageNode.title) {
-                               breadCrumbs(parentPaths)
-                               h1 { +pageNode.title }
-                               ul {
-                                       pageNode.subNodes.renderInto(this, pagePath, LoreArticleFormat.RAW_HTML)
-                               }
-                       }
-               }
-               
-               val pageMarkup = FactbookLoader.loadFactbook(pagePath)
-               if (pageType == StoredFileType.FILE && pageMarkup != null) {
-                       val pageHtml = pageMarkup.toRawHtml()
-                       
-                       val pageToC = TableOfContentsBuilder()
-                       pageMarkup.buildToC(pageToC)
-                       
-                       return rawPage(pageToC.toPageTitle(), pageToC.toOpenGraph()) {
-                               breadCrumbs(parentPaths)
-                               +pageHtml
-                       }
-               }
-       }
-       
-       return rawPage(pageNode.title) {
-               breadCrumbs(parentPaths)
-               h1 { +pageNode.title }
-               p {
-                       +"This factbook does not exist. Would you like to "
-                       a(href = href(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML))) { +"return to the table of contents" }
-                       +"?"
-               }
-       }
-}
-
-suspend fun ApplicationCall.loreArticlePage(pagePath: List<String>, format: LoreArticleFormat = LoreArticleFormat.HTML): HTML.() -> Unit {
-       val totalsData = processGuestbook()
-       
-       if (format == LoreArticleFormat.RAW_HTML)
-               return loreRawArticlePage(pagePath)
-       
-       val pageFile = StoragePath.articleDir / pagePath
-       val pageNode = pageFile.toArticleNode()
-       
-       val (canCommentAs, comments) = coroutineScope {
-               val canCommentAs = async { currentNation() }
-               val comments = async {
-                       CommentRenderData(Comment.getCommentsIn(pagePath).toList(), nationCache)
-               }
-               
-               canCommentAs.await() to comments.await()
-       }
-       
-       val pageType = FileStorage.instance.getType(pageFile)
-       val isValid = pageType != null && pageFile.isViewable
-       
-       if (isValid) {
-               if (pageType == StoredFileType.DIRECTORY) {
-                       val navbar = standardNavBar(pagePath.takeIf { it.isNotEmpty() })
-                       
-                       val sidebar = PageNavSidebar(
-                               listOf(
-                                       NavLink("#page-top", pageNode.title, aClasses = "left"),
-                                       NavLink("#comments", "Comments", aClasses = "left")
-                               )
-                       )
-                       
-                       return page(pageNode.title, navbar, sidebar) {
-                               section {
-                                       a { id = "page-top" }
-                                       h1 { +pageNode.title }
-                                       ul {
-                                               pageNode.subNodes.renderInto(this, pagePath, format = format)
-                                       }
-                               }
-                               
-                               finalSection(pagePath, canCommentAs, comments, totalsData)
-                       }
-               }
-               
-               val pageMarkup = FactbookLoader.loadFactbook(pagePath)
-               if (pageType == StoredFileType.FILE && pageMarkup != null) {
-                       val pageHtml = pageMarkup.toFactbookHtml()
-                       
-                       val pageToC = TableOfContentsBuilder()
-                       pageMarkup.buildToC(pageToC)
-                       
-                       val pageNav = pageToC.toNavBar() + NavLink("#comments", "Comments", aClasses = "left")
-                       
-                       val navbar = standardNavBar(pagePath)
-                       val sidebar = PageNavSidebar(pageNav)
-                       
-                       return page(pageToC.toPageTitle(), navbar, sidebar, pageToC.toOpenGraph()) {
-                               +pageHtml
-                               
-                               finalSection(pagePath, canCommentAs, comments, totalsData)
-                       }
-               }
-       }
-       
-       val title = pageNode.title
-       val navbar = standardNavBar(pagePath)
-       val sidebar = PageNavSidebar(
-               listOf(
-                       NavLink("#page-top", title, aClasses = "left"),
-                       NavLink("#comments", "Comments", aClasses = "left")
-               )
-       )
-       
-       return page(title, navbar, sidebar) {
-               section {
-                       a { id = "page-top" }
-                       h1 { +pageNode.title }
-                       p {
-                               +"This factbook does not exist. Would you like to "
-                               a(href = href(Root())) { +"return to the index page" }
-                               +"?"
-                       }
-               }
-               
-               finalSection(pagePath, canCommentAs, comments, totalsData)
-       }
-}
-
-context(ApplicationCall)
-private fun SectioningOrFlowContent.finalSection(pagePathParts: List<String>, canCommentAs: NationData?, comments: List<CommentRenderData>, totalsData: PageVisitTotals) {
-       section {
-               h2 {
-                       a { id = "comments" }
-                       +"Comments"
-               }
-               commentInput(pagePathParts, canCommentAs)
-               for (comment in comments)
-                       commentBox(comment, canCommentAs?.id)
-               
-               guestbook(totalsData)
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_prefs.kt
deleted file mode 100644 (file)
index 99307e9..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.auth.PageDoNotCacheAttributeKey
-import info.mechyrdia.route.KeyedEnumSerializer
-import io.ktor.server.application.*
-import kotlinx.html.*
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.serializer
-
-@Serializable(PageThemeSerializer::class)
-enum class PageTheme(val attributeValue: String?) {
-       SYSTEM(null),
-       LIGHT("light"),
-       DARK("dark");
-}
-
-object PageThemeSerializer : KeyedEnumSerializer<PageTheme>(PageTheme.entries, PageTheme::attributeValue)
-
-val ApplicationCall.pageTheme: PageTheme
-       get() = when (request.cookies["FACTBOOK_THEME"]) {
-               "light" -> PageTheme.LIGHT
-               "dark" -> PageTheme.DARK
-               else -> PageTheme.SYSTEM
-       }
-
-@Serializable(with = April1stModeSerializer::class)
-enum class April1stMode {
-       DEFAULT {
-               override val isEnabled: Boolean
-                       get() = isApril1st()
-       },
-       ALWAYS {
-               override val isEnabled: Boolean
-                       get() = true
-       },
-       NEVER {
-               override val isEnabled: Boolean
-                       get() = false
-       };
-       
-       abstract val isEnabled: Boolean
-}
-
-object April1stModeSerializer : KeyedEnumSerializer<April1stMode>(April1stMode.entries)
-
-val ApplicationCall.april1stMode: April1stMode
-       get() = when (request.cookies["APRIL_1ST_MODE"]) {
-               "always" -> April1stMode.ALWAYS
-               "never" -> April1stMode.NEVER
-               else -> April1stMode.DEFAULT
-       }
-
-class JoinToHtmlConsumer<E>(val iterator: Iterator<E>) {
-       inline fun <T : Tag> T.invokeReceiver(separator: T.() -> Unit, body: T.(E) -> Unit) {
-               var isFirst = true
-               for (item in iterator) {
-                       if (isFirst)
-                               isFirst = false
-                       else
-                               separator()
-                       body(item)
-               }
-       }
-       
-       context(T)
-       inline operator fun <T : Tag> invoke(separator: T.() -> Unit, body: T.(E) -> Unit) {
-               invokeReceiver(separator, body)
-       }
-}
-
-val <E> Iterable<E>.joinToHtml: JoinToHtmlConsumer<E>
-       get() = JoinToHtmlConsumer(iterator())
-
-inline fun <reified E : Enum<E>> FlowOrInteractiveOrPhrasingContent.preference(inputName: String, current: E, crossinline localize: (E) -> String) {
-       val serializer = serializer<E>() as? KeyedEnumSerializer<E> ?: throw UnsupportedOperationException("Serializer for ${E::class.simpleName} has not been declared as KeyedEnumSerializer")
-       val entries = serializer.entries
-       
-       entries.joinToHtml(Tag::br) { option ->
-               label {
-                       radioInput(name = inputName) {
-                               value = serializer.getKey(option) ?: "null"
-                               required = true
-                               checked = current == option
-                       }
-                       +Entities.nbsp
-                       +localize(option)
-               }
-       }
-}
-
-suspend fun ApplicationCall.clientSettingsPage(): HTML.() -> Unit {
-       attributes.put(PageDoNotCacheAttributeKey, true)
-       
-       val theme = pageTheme
-       val april1st = april1stMode
-       
-       return page("Client Preferences", standardNavBar()) {
-               section {
-                       h1 { +"Client Preferences" }
-                       p { +"This is the place where you can adjust your client preferences. Selecting an option changes it automatically, so you don't need to click any kind of \"save\" button. Also, note that preferences are saved per-browser in your cookies, so don't expect your client-side preferences to travel with you to other devices." }
-               }
-               section {
-                       h2 { +"Page Theme" }
-                       preference<PageTheme>("theme", theme) {
-                               when (it) {
-                                       PageTheme.SYSTEM -> "Chosen by Browser/System"
-                                       PageTheme.LIGHT -> "Light Theme"
-                                       PageTheme.DARK -> "Dark Theme"
-                               }
-                       }
-               }
-               section {
-                       h2 { +"April Fools' Day Mode" }
-                       preference<April1stMode>("april1st", april1st) {
-                               when (it) {
-                                       April1stMode.DEFAULT -> "Only on April 1st"
-                                       April1stMode.ALWAYS -> "Always"
-                                       April1stMode.NEVER -> "Never"
-                               }
-                       }
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt
deleted file mode 100644 (file)
index cffd232..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.JsonFileCodec
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-import info.mechyrdia.route.KeyedEnumSerializer
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.html.*
-import io.ktor.server.response.*
-import io.ktor.util.*
-import kotlinx.html.*
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.builtins.ListSerializer
-import kotlinx.serialization.json.buildJsonObject
-import kotlinx.serialization.json.put
-
-@Serializable
-data class Quote(
-       val quote: String,
-       val author: String,
-       val portrait: String,
-       val link: String
-) {
-       val fullPortrait: String
-               get() = if (portrait.startsWith("http://") || portrait.startsWith("https://"))
-                       portrait
-               else
-                       "https://mechyrdia.info/assets/images/$portrait"
-       
-       val fullLink: String
-               get() = if (link.startsWith("http://") || link.startsWith("https://"))
-                       link
-               else
-                       "https://mechyrdia.info/lore/$link"
-}
-
-private val quotesListGetter by storedData(StoragePath("quotes.json")) { jsonPath ->
-       FileStorage.instance.readFile(jsonPath)?.let {
-               JsonFileCodec.decodeFromString(ListSerializer(Quote.serializer()), String(it))
-       }
-}
-
-suspend fun getQuotesList() = quotesListGetter()!!
-
-suspend fun randomQuote(): Quote = getQuotesList().random()
-
-@Serializable(with = QuoteFormatSerializer::class)
-enum class QuoteFormat(val format: String?) {
-       HTML(null) {
-               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
-                       respondHtml(HttpStatusCode.OK, block = quote.toHtml(RANDOM_QUOTE_HTML_TITLE))
-               }
-       },
-       RAW_HTML("raw") {
-               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
-                       respondHtml(HttpStatusCode.OK, block = quote.toRawHtml(RANDOM_QUOTE_HTML_TITLE))
-               }
-       },
-       JSON("json") {
-               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
-                       respondText(quote.toJson(), contentType = ContentType.Application.Json)
-               }
-       },
-       XML("xml") {
-               override suspend fun ApplicationCall.respondQuote(quote: Quote) {
-                       respondText(quote.toXml(), contentType = ContentType.Application.Xml)
-               }
-       },
-       ;
-       
-       abstract suspend fun ApplicationCall.respondQuote(quote: Quote)
-}
-
-object QuoteFormatSerializer : KeyedEnumSerializer<QuoteFormat>(QuoteFormat.entries, QuoteFormat::format)
-
-const val RANDOM_QUOTE_HTML_TITLE = "Random Quote"
-
-fun Quote.toXml(standalone: Boolean = true): String {
-       return buildString {
-               if (standalone)
-                       appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
-               appendLine("<quote>")
-               append("<text>").append(quote.escapeHTML()).appendLine("</text>")
-               append("<author>").append(author.escapeHTML()).appendLine("</author>")
-               append("<portrait href=\"").append(fullPortrait.escapeHTML()).appendLine("\" />")
-               append("<link href=\"").append(fullLink.escapeHTML()).appendLine("\" />")
-               appendLine("</quote>")
-       }
-}
-
-fun Quote.toJson(): String {
-       return buildJsonObject {
-               put("text", quote)
-               put("author", author)
-               put("portrait", fullPortrait)
-               put("link", fullLink)
-       }.toString()
-}
-
-context(Quote)
-private fun FlowContent.quoteWithAttribution(pageTitle: String) {
-       h1 { +pageTitle }
-       blockQuote {
-               +quote
-       }
-       p {
-               style = "align:right"
-               unsafe { raw("&#x2015;") }
-               +Entities.nbsp
-               a(href = fullLink) { +author }
-       }
-}
-
-context(ApplicationCall)
-suspend fun Quote.toHtml(title: String): HTML.() -> Unit {
-       return page(title, standardNavBar(), QuoteOriginSidebar(author, fullPortrait, fullLink)) {
-               section {
-                       a { id = "page-top" }
-                       quoteWithAttribution(title)
-               }
-       }
-}
-
-context(ApplicationCall)
-fun Quote.toRawHtml(title: String): HTML.() -> Unit {
-       return rawPage(title) {
-               quoteWithAttribution(title)
-               p {
-                       style = "align:center"
-                       a(href = fullLink) {
-                               img(src = fullPortrait, alt = "Image of $author")
-                       }
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_robots.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_robots.kt
deleted file mode 100644 (file)
index a95aa07..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.FileStorage
-import info.mechyrdia.data.StoragePath
-
-private const val AVERAGE_FACTBOOK_INTRO_CHANGEFREQ = "daily"
-private const val AVERAGE_FACTBOOK_PAGE_CHANGEFREQ = "hourly"
-private const val FACTBOOK_INTRO_PRIORITY = "0.7"
-private const val FACTBOOK_PAGE_PRIORITY = "0.8"
-
-private suspend fun Appendable.renderLoreSitemap() {
-       for (page in allPages()) {
-               if (page.path.isViewable) {
-                       val lastModified = page.stat.updated
-                       
-                       appendLine("\t<url>")
-                       appendLine("\t\t<loc>https://mechyrdia.info/lore/${page.path}</loc>")
-                       appendLine("\t\t<lastmod>$lastModified</lastmod>")
-                       appendLine("\t\t<changefreq>$AVERAGE_FACTBOOK_PAGE_CHANGEFREQ</changefreq>")
-                       appendLine("\t\t<priority>$FACTBOOK_PAGE_PRIORITY</priority>")
-                       appendLine("\t</url>")
-               }
-       }
-}
-
-private suspend fun Appendable.renderIntroSitemap() {
-       val introFile = FileStorage.instance.statFile(StoragePath.Root / "intro.html") ?: return
-       val introMetaFile = FileStorage.instance.statFile(StoragePath.Root / "introMeta.json") ?: return
-       
-       val introLastModified = maxOf(introFile.updated, introMetaFile.updated)
-       
-       appendLine("\t<url>")
-       appendLine("\t\t<loc>https://mechyrdia.info/</loc>")
-       appendLine("\t\t<lastmod>$introLastModified</lastmod>")
-       appendLine("\t\t<changefreq>$AVERAGE_FACTBOOK_INTRO_CHANGEFREQ</changefreq>")
-       appendLine("\t\t<priority>$FACTBOOK_INTRO_PRIORITY</priority>")
-       appendLine("\t</url>")
-}
-
-suspend fun Appendable.generateSitemap() {
-       appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
-       appendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">")
-       renderIntroSitemap()
-       renderLoreSitemap()
-       appendLine("</urlset>")
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt
deleted file mode 100644 (file)
index 1a53259..0000000
+++ /dev/null
@@ -1,291 +0,0 @@
-package info.mechyrdia.lore
-
-import com.mongodb.client.model.Sorts
-import info.mechyrdia.OwnerNationId
-import info.mechyrdia.data.*
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.util.*
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.flow.filterNot
-import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.flow.toList
-import java.time.Instant
-import java.time.ZoneOffset
-import java.time.format.DateTimeFormatter
-
-data class StoragePathWithStat(val path: StoragePath, val stat: StoredFileStats)
-
-private suspend fun ArticleNode.addPages(base: StoragePath): List<StoragePathWithStat> {
-       if (!this.isViewable)
-               return emptyList()
-       val path = base / name
-       val stat = FileStorage.instance.statFile(path)
-       return if (stat != null)
-               listOf(StoragePathWithStat(path, stat))
-       else coroutineScope {
-               subNodes.map { subNode ->
-                       async {
-                               subNode.addPages(path)
-                       }
-               }.awaitAll().flatten()
-       }
-}
-
-suspend fun allPages(): List<StoragePathWithStat> {
-       return coroutineScope {
-               rootArticleNodeList().map { subNode ->
-                       async {
-                               subNode.addPages(StoragePath.articleDir)
-                       }
-               }.awaitAll().flatten()
-       }
-}
-
-suspend fun Appendable.generateRecentPageEdits() {
-       val pages = allPages().sortedByDescending { it.stat.updated }
-       
-       val mostRecentChange = pages.firstOrNull()?.stat?.updated
-       
-       RssChannel(
-               title = "Recently Edited Factbooks | The Hour of Decision",
-               link = "https://mechyrdia.info",
-               description = "An RSS feed containing all factbooks in The Hour of Decision, in order of most recently edited.",
-               pubDate = mostRecentChange,
-               lastBuildDate = mostRecentChange,
-               ttl = 30,
-               categories = listOf(
-                       RssCategory(domain = "https://nationstates.net", category = "Mechyrdia")
-               ),
-               items = coroutineScope {
-                       pages.map { page ->
-                               async {
-                                       val pageLink = page.path.elements.drop(1)
-                                       val pageMarkup = FactbookLoader.loadFactbook(pageLink) ?: return@async null
-                                       
-                                       val pageToC = TableOfContentsBuilder()
-                                       pageMarkup.buildToC(pageToC)
-                                       val pageOg = pageToC.toOpenGraph()
-                                       
-                                       val imageEnclosure = pageOg?.image?.let { url ->
-                                               val assetPath = url.removePrefix("https://mechyrdia.info/assets/")
-                                               val file = StoragePath.assetDir / assetPath
-                                               RssItemEnclosure(
-                                                       url = url,
-                                                       length = FileStorage.instance.statFile(file)?.size ?: 0L,
-                                                       type = ContentType.defaultForFileExtension(assetPath.substringAfterLast('.')).toString()
-                                               )
-                                       }
-                                       
-                                       RssItem(
-                                               title = pageToC.toPageTitle(),
-                                               description = pageOg?.desc,
-                                               link = "https://mechyrdia.info/lore${pageLink.joinToString { "/$it" }}",
-                                               author = null,
-                                               comments = "https://mechyrdia.info/lore${pageLink.joinToString { "/$it" }}#comments",
-                                               enclosure = imageEnclosure,
-                                               pubDate = page.stat.updated
-                                       )
-                               }
-                       }.awaitAll().filterNotNull()
-               }
-       ).toXml(this)
-}
-
-suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): Appendable.() -> Unit {
-       val currNation = currentNation()
-       
-       val validLimits = 1..100
-       
-       if (limit !in validLimits)
-               return RssChannel(
-                       title = "Recent Comments - Error | The Hour of Decision",
-                       link = "https://mechyrdia.info/comment/recent",
-                       description = "Comment limit must be between ${validLimits.first} and ${validLimits.last}, got $limit",
-                       pubDate = null,
-                       lastBuildDate = Instant.now(),
-                       ttl = 120,
-               )::toXml
-       
-       val comments = CommentRenderData(
-               Comment.Table
-                       .sorted(Sorts.descending(Comment::submittedAt.serialName))
-                       .let { flow ->
-                               if (currNation?.id == OwnerNationId)
-                                       flow
-                               else flow.filterNot { comment ->
-                                       comment.submittedBy != currNation?.id && NationData.get(comment.submittedBy).isBanned
-                               }
-                       }
-                       .take(limit)
-                       .toList(),
-               nationCache
-       )
-       
-       val mostRecentComment = comments.firstOrNull()?.let { it.lastEdit ?: it.submittedAt }
-       
-       return RssChannel(
-               title = "Recent Comments | The Hour of Decision",
-               link = "https://mechyrdia.info/comment/recent",
-               description = "An RSS feed containing the $limit most recently-submitted comments",
-               pubDate = mostRecentComment,
-               lastBuildDate = mostRecentComment,
-               ttl = 60,
-               categories = listOf(
-                       RssCategory(domain = "https://nationstates.net", category = "Mechyrdia")
-               ),
-               items = comments.map { comment ->
-                       RssItem(
-                               title = "Comment by ${comment.submittedBy.name} on https://mechyrdia.info/lore/${comment.submittedIn}",
-                               description = comment.contentsRaw.parseAs(ParserTree::toCommentPlainText),
-                               link = "https://mechyrdia.info/comment/view/${comment.id}",
-                               author = null,
-                               comments = "https://mechyrdia.info/lore/${comment.submittedIn}#comment-${comment.id}",
-                               pubDate = comment.lastEdit ?: comment.submittedAt,
-                               categories = listOf(
-                                       RssCategory(domain = "https://nationstates.net", category = comment.submittedBy.name)
-                               )
-                       )
-               }
-       )::toXml
-}
-
-data class RssCategory(
-       val category: String,
-       val domain: String? = null
-) {
-       fun toXml(appendable: Appendable) {
-               appendable.append("<category")
-               if (domain == null)
-                       appendable.append(">")
-               else
-                       appendable.append(" domain=\"").append(domain.escapeHTML()).append("\">")
-               appendable.append(category.escapeHTML()).appendLine("</category>")
-       }
-}
-
-data class RssChannelImage(
-       val url: String,
-       val title: String,
-       val link: String,
-) {
-       fun toXml(appendable: Appendable) {
-               appendable.appendLine("<image>")
-               appendable.append("<url>").append(url.escapeHTML()).appendLine("</url>")
-               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
-               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
-               appendable.appendLine("</image>")
-       }
-}
-
-const val DEFAULT_RSS_COPYRIGHT = "Copyright 2022 Lanius Trolling"
-const val DEFAULT_RSS_EMAIL = "lanius@laniustrolling.dev (Lanius Trolling)"
-
-val RssDateFormat: DateTimeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME
-fun Instant.toXml() = RssDateFormat.format(atOffset(ZoneOffset.UTC)).escapeHTML()
-
-data class RssChannel(
-       val title: String,
-       val link: String,
-       val description: String,
-       val language: String? = "en-us",
-       val copyright: String? = DEFAULT_RSS_COPYRIGHT,
-       val managingEditor: String? = DEFAULT_RSS_EMAIL,
-       val webMaster: String? = managingEditor,
-       val pubDate: Instant? = null,
-       val lastBuildDate: Instant? = null,
-       val ttl: Int? = null,
-       val image: RssChannelImage? = null,
-       val categories: List<RssCategory> = emptyList(),
-       val items: List<RssItem> = emptyList(),
-) {
-       fun toXml(appendable: Appendable) {
-               appendable.appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
-               appendable.appendLine("<rss version=\"2.0\">")
-               appendable.appendLine("<channel>")
-               
-               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
-               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
-               appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
-               
-               if (language != null)
-                       appendable.append("<language>").append(language.escapeHTML()).appendLine("</language>")
-               if (copyright != null)
-                       appendable.append("<copyright>").append(copyright.escapeHTML()).appendLine("</copyright>")
-               if (managingEditor != null)
-                       appendable.append("<managingEditor>").append(managingEditor.escapeHTML()).appendLine("</managingEditor>")
-               if (webMaster != null)
-                       appendable.append("<webMaster>").append(webMaster.escapeHTML()).appendLine("</webMaster>")
-               if (pubDate != null)
-                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
-               if (lastBuildDate != null)
-                       appendable.append("<lastBuildDate>").append(lastBuildDate.toXml()).appendLine("</lastBuildDate>")
-               if (ttl != null)
-                       appendable.append("<ttl>").append(ttl.toString()).appendLine("</ttl>")
-               
-               image?.toXml(appendable)
-               
-               for (category in categories)
-                       category.toXml(appendable)
-               for (item in items)
-                       item.toXml(appendable)
-               
-               appendable.appendLine("</channel>")
-               appendable.appendLine("</rss>")
-       }
-}
-
-data class RssItemEnclosure(
-       val url: String,
-       val length: Long,
-       val type: String,
-) {
-       fun toXml(appendable: Appendable) {
-               appendable.append("<enclosure ")
-                       .append("url=\"").append(url.escapeHTML()).append("\" ")
-                       .append("length=\"").append(length.toString()).append("\" ")
-                       .append("type=\"").append(type.escapeHTML()).append("\" ")
-                       .appendLine("/>")
-       }
-}
-
-data class RssItem(
-       val title: String? = null,
-       val description: String? = null,
-       val link: String? = null,
-       val author: String? = DEFAULT_RSS_EMAIL,
-       val comments: String? = null,
-       val enclosure: RssItemEnclosure? = null,
-       val pubDate: Instant? = null,
-       val categories: List<RssCategory> = emptyList(),
-) {
-       init {
-               require(title != null || description != null) { "Either title or description must be provided, got null for both" }
-       }
-       
-       fun toXml(appendable: Appendable) {
-               appendable.appendLine("<item>")
-               
-               if (title != null)
-                       appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
-               if (description != null)
-                       appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
-               if (link != null)
-                       appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
-               if (author != null)
-                       appendable.append("<author>").append(author.escapeHTML()).appendLine("</author>")
-               if (comments != null)
-                       appendable.append("<comments>").append(comments.escapeHTML()).appendLine("</comments>")
-               enclosure?.toXml(appendable)
-               
-               if (pubDate != null)
-                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
-               
-               for (category in categories)
-                       category.toXml(appendable)
-               
-               appendable.appendLine("</item>")
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt
new file mode 100644 (file)
index 0000000..30cc0ae
--- /dev/null
@@ -0,0 +1,68 @@
+package info.mechyrdia.route
+
+import info.mechyrdia.lore.TextAlignment
+import kotlinx.html.FlowContent
+import kotlinx.html.p
+import kotlinx.html.textArea
+import kotlinx.serialization.Serializable
+
+@Serializable
+class LoginPayload(override val csrfToken: String? = null, val nation: String, val checksum: String, val token: String) : CsrfProtectedResourcePayload
+
+@Serializable
+class LogoutPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
+@Serializable
+class NewCommentPayload(override val csrfToken: String? = null, val comment: String) : CsrfProtectedResourcePayload {
+       override fun FlowContent.displayRetryData() {
+               p { +"The comment you tried to submit had been preserved here:" }
+               textArea {
+                       readonly = true
+                       +comment
+               }
+       }
+}
+
+@Serializable
+class EditCommentPayload(override val csrfToken: String? = null, val comment: String) : CsrfProtectedResourcePayload {
+       override fun FlowContent.displayRetryData() {
+               p { +"The comment you tried to submit had been preserved here:" }
+               textArea {
+                       readonly = true
+                       +comment
+               }
+       }
+}
+
+@Serializable
+class DeleteCommentPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminBanUserPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminUnbanUserPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminVfsCopyFilePayload(val from: String, override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminVfsDeleteFilePayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminVfsMkDirPayload(override val csrfToken: String? = null, val directory: String) : CsrfProtectedResourcePayload
+
+@Serializable
+class AdminVfsRmDirPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
+
+@Serializable
+class MechyrdiaSansPayload(val bold: Boolean = false, val italic: Boolean = false, val align: TextAlignment = TextAlignment.LEFT, val lines: List<String>)
+
+@Serializable
+class TylanLanguagePayload(val lines: List<String>)
+
+@Serializable
+class PokhwalishLanguagePayload(val lines: List<String>)
+
+@Serializable
+class PreviewCommentPayload(val lines: List<String>)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt
new file mode 100644 (file)
index 0000000..0d17cfe
--- /dev/null
@@ -0,0 +1,72 @@
+package info.mechyrdia.route
+
+import info.mechyrdia.auth.UserSession
+import info.mechyrdia.auth.token
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.request.*
+import io.ktor.server.sessions.*
+import kotlinx.html.A
+import kotlinx.html.FORM
+import kotlinx.html.FlowContent
+import kotlinx.html.hiddenInput
+import java.time.Instant
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.collections.set
+
+data class CsrfPayload(
+       val route: String,
+       val remoteAddress: String,
+       val userAgent: String?,
+       val userAccount: Id<NationData>?,
+       val expires: Instant
+)
+
+fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(7200)) =
+       CsrfPayload(
+               route = route,
+               remoteAddress = request.origin.remoteAddress,
+               userAgent = request.userAgent(),
+               userAccount = sessions.get<UserSession>()?.nationId,
+               expires = withExpiration
+       )
+
+private val csrfMap = ConcurrentHashMap<String, CsrfPayload>()
+
+data class CsrfFailedException(override val message: String, val payload: CsrfProtectedResourcePayload) : RuntimeException(message)
+
+interface CsrfProtectedResourcePayload {
+       val csrfToken: String?
+       
+       suspend fun ApplicationCall.verifyCsrfToken(route: String = request.uri) {
+               val token = csrfToken ?: throw CsrfFailedException("The submitted CSRF token is not present", this@CsrfProtectedResourcePayload)
+               val check = csrfMap.remove(token) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload)
+               val payload = csrfPayload(route, check.expires)
+               if (check != payload)
+                       throw CsrfFailedException("The submitted CSRF token does not match", this@CsrfProtectedResourcePayload)
+               if (payload.expires < Instant.now())
+                       throw CsrfFailedException("The submitted CSRF token has expired", this@CsrfProtectedResourcePayload)
+       }
+       
+       fun FlowContent.displayRetryData() {}
+}
+
+fun ApplicationCall.createCsrfToken(route: String = request.origin.uri): String {
+       return token().also { csrfMap[it] = csrfPayload(route) }
+}
+
+context(ApplicationCall)
+fun A.installCsrfToken(route: String = href) {
+       attributes["data-method"] = "post"
+       attributes["data-csrf-token"] = token().also { csrfMap[it] = csrfPayload(route) }
+}
+
+context(ApplicationCall)
+fun FORM.installCsrfToken(route: String = action) {
+       hiddenInput {
+               name = "csrfToken"
+               value = token().also { csrfMap[it] = csrfPayload(route) }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceHandler.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceHandler.kt
new file mode 100644 (file)
index 0000000..0d95e89
--- /dev/null
@@ -0,0 +1,90 @@
+package info.mechyrdia.route
+
+import io.ktor.http.*
+import io.ktor.resources.serialization.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.resources.*
+import io.ktor.server.routing.Route
+import io.ktor.util.pipeline.*
+import kotlinx.serialization.DeserializationStrategy
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationStrategy
+import kotlinx.serialization.StringFormat
+import kotlinx.serialization.builtins.nullable
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.modules.SerializersModule
+import kotlin.enums.EnumEntries
+
+interface ResourceHandler {
+       suspend fun PipelineContext<Unit, ApplicationCall>.handleCall()
+}
+
+interface ResourceReceiver<P : Any> {
+       suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: P)
+}
+
+interface ResourceFilter {
+       suspend fun PipelineContext<Unit, ApplicationCall>.filterCall()
+}
+
+inline fun <reified T : ResourceHandler> Route.get() {
+       get<T> { resource ->
+               with(resource) {
+                       handleCall()
+               }
+       }
+}
+
+inline fun <reified T : ResourceReceiver<P>, reified P : Any> Route.post() {
+       post<T, P> { resource, payload ->
+               with(resource) { handleCall(payload) }
+       }
+}
+
+inline fun <reified T : ResourceReceiver<P>, reified P : MultiPartPayload> Route.postMultipart() {
+       post<T> { resource ->
+               with(resource) { handleCall(payloadProcessor<P>().process(call.receiveMultipart())) }
+       }
+}
+
+abstract class KeyedEnumSerializer<E : Enum<E>>(val entries: EnumEntries<E>, val getKey: (E) -> String? = { it.name }) : KSerializer<E> {
+       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KeyedEnumSerializer<${entries.first()::class.qualifiedName}>", PrimitiveKind.STRING)
+       
+       private val inner = String.serializer().nullable
+       private val keyMap = entries.associateBy { getKey(it)?.lowercase() }
+       private val default = keyMap[null] ?: entries.first()
+       
+       init {
+               assert(keyMap.size == entries.size)
+       }
+       
+       override fun serialize(encoder: Encoder, value: E) {
+               inner.serialize(encoder, getKey(value))
+       }
+       
+       override fun deserialize(decoder: Decoder): E {
+               return keyMap[inner.deserialize(decoder)?.lowercase()] ?: default
+       }
+}
+
+class FormUrlEncodedFormat(private val resourcesFormat: ResourcesFormat) : StringFormat {
+       override val serializersModule: SerializersModule = resourcesFormat.serializersModule
+       
+       override fun <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
+               return resourcesFormat.encodeToParameters(serializer as KSerializer<T>, value).formUrlEncode()
+       }
+       
+       override fun <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
+               return resourcesFormat.decodeFromParameters(deserializer as KSerializer<T>, string.replace("+", "%20").parseUrlEncodedParameters())
+       }
+}
+
+inline fun <reified T : Any> Application.href(resource: T, hash: String? = null): String = URLBuilder().also { href(resource, it) }.build().fullPath + hash?.let { "#$it" }.orEmpty()
+inline fun <reified T : Any> ApplicationCall.href(resource: T, hash: String? = null) = application.href(resource, hash)
+inline fun <reified T : Any> PipelineContext<Unit, ApplicationCall>.href(resource: T, hash: String? = null) = application.href(resource, hash)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceMultipart.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceMultipart.kt
new file mode 100644 (file)
index 0000000..51ac7b6
--- /dev/null
@@ -0,0 +1,52 @@
+package info.mechyrdia.route
+
+import io.ktor.http.content.*
+import kotlin.reflect.full.companionObjectInstance
+
+interface MultiPartPayload : AutoCloseable {
+       val payload: List<PartData>
+       
+       override fun close() {
+               for (data in payload)
+                       data.dispose()
+       }
+}
+
+interface MultiPartPayloadProcessor<P : MultiPartPayload> {
+       suspend fun process(data: MultiPartData): P
+}
+
+inline fun <reified P : MultiPartPayload> payloadProcessor(): MultiPartPayloadProcessor<P> {
+       @Suppress("UNCHECKED_CAST")
+       return P::class.companionObjectInstance as MultiPartPayloadProcessor<P>
+}
+
+data class CsrfProtectedMultiPartPayload(
+       override val csrfToken: String? = null,
+       override val payload: List<PartData>
+) : CsrfProtectedResourcePayload, MultiPartPayload {
+       companion object : MultiPartPayloadProcessor<CsrfProtectedMultiPartPayload> {
+               override suspend fun process(data: MultiPartData): CsrfProtectedMultiPartPayload {
+                       var csrfToken: String? = null
+                       val payload = mutableListOf<PartData>()
+                       
+                       data.forEachPart { part ->
+                               if (part is PartData.FormItem && part.name == "csrfToken")
+                                       csrfToken = part.value
+                               else payload.add(part)
+                       }
+                       
+                       return CsrfProtectedMultiPartPayload(csrfToken, payload)
+               }
+       }
+}
+
+data class PlainMultiPartPayload(
+       override val payload: List<PartData>
+) : MultiPartPayload {
+       companion object : MultiPartPayloadProcessor<PlainMultiPartPayload> {
+               override suspend fun process(data: MultiPartData): PlainMultiPartPayload {
+                       return PlainMultiPartPayload(data.readAllParts())
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt
new file mode 100644 (file)
index 0000000..f1a8d85
--- /dev/null
@@ -0,0 +1,450 @@
+package info.mechyrdia.route
+
+import info.mechyrdia.auth.loginPage
+import info.mechyrdia.auth.loginRoute
+import info.mechyrdia.auth.logoutRoute
+import info.mechyrdia.data.*
+import info.mechyrdia.lore.*
+import io.ktor.http.*
+import io.ktor.http.content.*
+import io.ktor.resources.*
+import io.ktor.server.application.*
+import io.ktor.server.html.*
+import io.ktor.server.plugins.*
+import io.ktor.server.response.*
+import io.ktor.util.*
+import io.ktor.util.pipeline.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+
+val ErrorMessageAttributeKey = AttributeKey<String>("ErrorMessage")
+
+@Resource("/")
+class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
+       override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+               error?.let { call.attributes.put(ErrorMessageAttributeKey, it) }
+       }
+       
+       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+               filterCall()
+               call.respondHtml(HttpStatusCode.OK, call.loreIntroPage())
+       }
+       
+       @Resource("assets/{path...}")
+       class AssetFile(val path: List<String>, val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondAsset(StoragePath.assetDir / path)
+               }
+       }
+       
+       @Resource("lore/{path...}")
+       class LorePage(val path: List<String>, val format: LoreArticleFormat = LoreArticleFormat.HTML, val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondHtml(HttpStatusCode.OK, call.loreArticlePage(path, format))
+               }
+       }
+       
+       @Resource("map")
+       class GalaxyMap(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondStoredFile(call.galaxyMapPage())
+               }
+       }
+       
+       @Resource("quote")
+       class RandomQuote(val format: QuoteFormat = QuoteFormat.HTML, val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       with(format) { call.respondQuote(randomQuote()) }
+               }
+       }
+       
+       @Resource("robots.txt")
+       class RobotsTxt(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondStoredFile(StoragePath.Root / "robots.txt")
+               }
+       }
+       
+       @Resource("sitemap.xml")
+       class SitemapXml(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondText(buildString { generateSitemap() }, ContentType.Application.Xml)
+               }
+       }
+       
+       @Resource("edits.rss")
+       class RecentEditsRss(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondText(buildString { generateRecentPageEdits() }, ContentType.Application.Rss)
+               }
+       }
+       
+       @Resource("comments.rss")
+       class RecentCommentsRss(val limit: Int?, val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondText(buildString(call.recentCommentsRssFeedGenerator(limit ?: 10)), ContentType.Application.Rss)
+               }
+       }
+       
+       @Resource("preferences")
+       class ClientPreferences(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondHtml(HttpStatusCode.OK, call.clientSettingsPage())
+               }
+       }
+       
+       @Resource("auth")
+       class Auth(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+               }
+               
+               @Resource("login")
+               class LoginPage(val auth: Auth = Auth()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(auth) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.loginPage())
+                       }
+               }
+               
+               @Resource("login")
+               class LoginPost(val auth: Auth = Auth()) : ResourceReceiver<LoginPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: LoginPayload) {
+                               with(auth) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.loginRoute(payload.nation, payload.checksum, payload.token)
+                       }
+               }
+               
+               @Resource("logout")
+               class LogoutPost(val auth: Auth = Auth()) : ResourceReceiver<LogoutPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: LogoutPayload) {
+                               with(auth) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.logoutRoute()
+                       }
+               }
+       }
+       
+       @Resource("comment")
+       class Comments(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+               }
+               
+               @Resource("help")
+               class HelpPage(val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.commentHelpPage())
+                       }
+               }
+               
+               @Resource("recent")
+               class RecentPage(val limit: Int? = null, val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.recentCommentsPage(limit))
+                       }
+               }
+               
+               @Resource("new/{path...}")
+               class NewPost(val path: List<String>, val comments: Comments = Comments()) : ResourceReceiver<NewCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: NewCommentPayload) {
+                               with(comments) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.newCommentRoute(path, payload.comment)
+                       }
+               }
+               
+               @Resource("view/{id}")
+               class ViewPage(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.viewCommentRoute(id)
+                       }
+               }
+               
+               @Resource("edit/{id}")
+               class EditPost(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceReceiver<EditCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: EditCommentPayload) {
+                               with(comments) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.editCommentRoute(id, payload.comment)
+                       }
+               }
+               
+               @Resource("delete/{id}")
+               class DeleteConfirmPage(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(comments) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.deleteCommentPage(id))
+                       }
+               }
+               
+               @Resource("delete/{id}")
+               class DeleteConfirmPost(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceReceiver<DeleteCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: DeleteCommentPayload) {
+                               with(comments) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.deleteCommentRoute(id)
+                       }
+               }
+       }
+       
+       @Resource("user")
+       class User(val root: Root = Root()) : ResourceHandler, ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+               }
+               
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       filterCall()
+                       call.currentUserPage()
+               }
+               
+               @Resource("{id}")
+               class ById(val id: Id<NationData>, val user: User = User()) : ResourceHandler {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                               with(user) { filterCall() }
+                               
+                               call.respondHtml(HttpStatusCode.OK, call.userPage(id))
+                       }
+               }
+       }
+       
+       @Resource("admin")
+       class Admin(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+                       call.ownerNationOnly()
+               }
+               
+               @Resource("ban/{id}")
+               class Ban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminBanUserPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminBanUserPayload) {
+                               with(admin) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.adminBanUserRoute(id)
+                       }
+               }
+               
+               @Resource("unban/{id}")
+               class Unban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminUnbanUserPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminUnbanUserPayload) {
+                               with(admin) { filterCall() }
+                               with(payload) { call.verifyCsrfToken() }
+                               
+                               call.adminUnbanUserRoute(id)
+                       }
+               }
+               
+               @Resource("vfs")
+               class Vfs(val admin: Admin = Admin()) : ResourceFilter {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                               with(admin) { filterCall() }
+                       }
+                       
+                       @Resource("inline/{path...}")
+                       class Inline(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.response.header(HttpHeaders.ContentDisposition, "inline")
+                                       call.adminPreviewFile(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("download/{path...}")
+                       class Download(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.response.header(HttpHeaders.ContentDisposition, "attachment; filename=\"${path.last()}\"")
+                                       call.adminPreviewFile(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("view/{path...}")
+                       class View(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.respondHtml(HttpStatusCode.OK, call.adminViewVfs(StoragePath(path)))
+                               }
+                       }
+                       
+                       @Resource("copy/{path...}")
+                       class CopyPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.respondHtml(HttpStatusCode.OK, call.adminShowCopyFile(StoragePath(path)))
+                               }
+                       }
+                       
+                       @Resource("copy/{path...}")
+                       class CopyPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsCopyFilePayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsCopyFilePayload) {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.adminDoCopyFile(StoragePath(payload.from), StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("upload/{path...}")
+                       class Upload(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<CsrfProtectedMultiPartPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: CsrfProtectedMultiPartPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       val fileItem = payload.payload.filterIsInstance<PartData.FileItem>().singleOrNull()
+                                               ?: throw MissingRequestParameterException("file")
+                                       
+                                       call.adminUploadFile(StoragePath(path), fileItem)
+                               }
+                       }
+                       
+                       @Resource("overwrite/{path...}")
+                       class Overwrite(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<CsrfProtectedMultiPartPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: CsrfProtectedMultiPartPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       val fileItem = payload.payload.filterIsInstance<PartData.FileItem>().singleOrNull()
+                                               ?: throw MissingRequestParameterException("file")
+                                       
+                                       call.adminOverwriteFile(StoragePath(path), fileItem)
+                               }
+                       }
+                       
+                       @Resource("delete/{path...}")
+                       class DeleteConfirmPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.adminConfirmDeleteFile(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("delete/{path...}")
+                       class DeleteConfirmPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsDeleteFilePayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsDeleteFilePayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       call.adminDeleteFile(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("mkdir/{path...}")
+                       class MkDir(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsMkDirPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsMkDirPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       call.adminMakeDirectory(StoragePath(path), payload.directory)
+                               }
+                       }
+                       
+                       @Resource("rmdir/{path...}")
+                       class RmDirConfirmPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                                       with(vfs) { filterCall() }
+                                       
+                                       call.adminConfirmRemoveDirectory(StoragePath(path))
+                               }
+                       }
+                       
+                       @Resource("rmdir/{path...}")
+                       class RmDirConfirmPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsRmDirPayload> {
+                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsRmDirPayload) {
+                                       with(vfs) { filterCall() }
+                                       with(payload) { call.verifyCsrfToken() }
+                                       
+                                       call.adminRemoveDirectory(StoragePath(path))
+                               }
+                       }
+               }
+       }
+       
+       @Resource("utils")
+       class Utils(val root: Root = Root()) : ResourceFilter {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
+                       with(root) { filterCall() }
+                       
+                       delay(250L)
+               }
+               
+               @Resource("mechyrdia-sans")
+               class MechyrdiaSans(val utils: Utils = Utils()) : ResourceReceiver<MechyrdiaSansPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: MechyrdiaSansPayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(withContext(Dispatchers.Default) {
+                                       MechyrdiaSansFont.renderTextToSvg(payload.lines.joinToString(separator = "\n") { it.trim() }, payload.bold, payload.italic, payload.align)
+                               }, ContentType.Image.SVG)
+                       }
+               }
+               
+               @Resource("tylan-lang")
+               class TylanLanguage(val utils: Utils = Utils()) : ResourceReceiver<TylanLanguagePayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: TylanLanguagePayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(TylanAlphabetFont.tylanToFontAlphabet(payload.lines.joinToString(separator = "\n")))
+                       }
+               }
+               
+               @Resource("pokhwal-lang")
+               class PokhwalishLanguage(val utils: Utils = Utils()) : ResourceReceiver<PokhwalishLanguagePayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: PokhwalishLanguagePayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(PokhwalishAlphabetFont.pokhwalToFontAlphabet(payload.lines.joinToString(separator = "\n")))
+                       }
+               }
+               
+               @Resource("preview-comment")
+               class PreviewComment(val utils: Utils = Utils()) : ResourceReceiver<PreviewCommentPayload> {
+                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: PreviewCommentPayload) {
+                               with(utils) { filterCall() }
+                               
+                               call.respondText(
+                                       text = payload.lines.joinToString(separator = "\n").parseAs(ParserTree::toCommentHtml).toFragment(),
+                                       contentType = ContentType.Text.Html
+                               )
+                       }
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_bodies.kt
deleted file mode 100644 (file)
index 30cc0ae..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-package info.mechyrdia.route
-
-import info.mechyrdia.lore.TextAlignment
-import kotlinx.html.FlowContent
-import kotlinx.html.p
-import kotlinx.html.textArea
-import kotlinx.serialization.Serializable
-
-@Serializable
-class LoginPayload(override val csrfToken: String? = null, val nation: String, val checksum: String, val token: String) : CsrfProtectedResourcePayload
-
-@Serializable
-class LogoutPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
-
-@Serializable
-class NewCommentPayload(override val csrfToken: String? = null, val comment: String) : CsrfProtectedResourcePayload {
-       override fun FlowContent.displayRetryData() {
-               p { +"The comment you tried to submit had been preserved here:" }
-               textArea {
-                       readonly = true
-                       +comment
-               }
-       }
-}
-
-@Serializable
-class EditCommentPayload(override val csrfToken: String? = null, val comment: String) : CsrfProtectedResourcePayload {
-       override fun FlowContent.displayRetryData() {
-               p { +"The comment you tried to submit had been preserved here:" }
-               textArea {
-                       readonly = true
-                       +comment
-               }
-       }
-}
-
-@Serializable
-class DeleteCommentPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
-
-@Serializable
-class AdminBanUserPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
-
-@Serializable
-class AdminUnbanUserPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
-
-@Serializable
-class AdminVfsCopyFilePayload(val from: String, override val csrfToken: String? = null) : CsrfProtectedResourcePayload
-
-@Serializable
-class AdminVfsDeleteFilePayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
-
-@Serializable
-class AdminVfsMkDirPayload(override val csrfToken: String? = null, val directory: String) : CsrfProtectedResourcePayload
-
-@Serializable
-class AdminVfsRmDirPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
-
-@Serializable
-class MechyrdiaSansPayload(val bold: Boolean = false, val italic: Boolean = false, val align: TextAlignment = TextAlignment.LEFT, val lines: List<String>)
-
-@Serializable
-class TylanLanguagePayload(val lines: List<String>)
-
-@Serializable
-class PokhwalishLanguagePayload(val lines: List<String>)
-
-@Serializable
-class PreviewCommentPayload(val lines: List<String>)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_csrf.kt
deleted file mode 100644 (file)
index 0d17cfe..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-package info.mechyrdia.route
-
-import info.mechyrdia.auth.UserSession
-import info.mechyrdia.auth.token
-import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
-import io.ktor.server.application.*
-import io.ktor.server.plugins.*
-import io.ktor.server.request.*
-import io.ktor.server.sessions.*
-import kotlinx.html.A
-import kotlinx.html.FORM
-import kotlinx.html.FlowContent
-import kotlinx.html.hiddenInput
-import java.time.Instant
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.collections.set
-
-data class CsrfPayload(
-       val route: String,
-       val remoteAddress: String,
-       val userAgent: String?,
-       val userAccount: Id<NationData>?,
-       val expires: Instant
-)
-
-fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(7200)) =
-       CsrfPayload(
-               route = route,
-               remoteAddress = request.origin.remoteAddress,
-               userAgent = request.userAgent(),
-               userAccount = sessions.get<UserSession>()?.nationId,
-               expires = withExpiration
-       )
-
-private val csrfMap = ConcurrentHashMap<String, CsrfPayload>()
-
-data class CsrfFailedException(override val message: String, val payload: CsrfProtectedResourcePayload) : RuntimeException(message)
-
-interface CsrfProtectedResourcePayload {
-       val csrfToken: String?
-       
-       suspend fun ApplicationCall.verifyCsrfToken(route: String = request.uri) {
-               val token = csrfToken ?: throw CsrfFailedException("The submitted CSRF token is not present", this@CsrfProtectedResourcePayload)
-               val check = csrfMap.remove(token) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload)
-               val payload = csrfPayload(route, check.expires)
-               if (check != payload)
-                       throw CsrfFailedException("The submitted CSRF token does not match", this@CsrfProtectedResourcePayload)
-               if (payload.expires < Instant.now())
-                       throw CsrfFailedException("The submitted CSRF token has expired", this@CsrfProtectedResourcePayload)
-       }
-       
-       fun FlowContent.displayRetryData() {}
-}
-
-fun ApplicationCall.createCsrfToken(route: String = request.origin.uri): String {
-       return token().also { csrfMap[it] = csrfPayload(route) }
-}
-
-context(ApplicationCall)
-fun A.installCsrfToken(route: String = href) {
-       attributes["data-method"] = "post"
-       attributes["data-csrf-token"] = token().also { csrfMap[it] = csrfPayload(route) }
-}
-
-context(ApplicationCall)
-fun FORM.installCsrfToken(route: String = action) {
-       hiddenInput {
-               name = "csrfToken"
-               value = token().also { csrfMap[it] = csrfPayload(route) }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_handler.kt
deleted file mode 100644 (file)
index 0d95e89..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-package info.mechyrdia.route
-
-import io.ktor.http.*
-import io.ktor.resources.serialization.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.resources.*
-import io.ktor.server.routing.Route
-import io.ktor.util.pipeline.*
-import kotlinx.serialization.DeserializationStrategy
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.SerializationStrategy
-import kotlinx.serialization.StringFormat
-import kotlinx.serialization.builtins.nullable
-import kotlinx.serialization.builtins.serializer
-import kotlinx.serialization.descriptors.PrimitiveKind
-import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
-import kotlinx.serialization.descriptors.SerialDescriptor
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-import kotlinx.serialization.modules.SerializersModule
-import kotlin.enums.EnumEntries
-
-interface ResourceHandler {
-       suspend fun PipelineContext<Unit, ApplicationCall>.handleCall()
-}
-
-interface ResourceReceiver<P : Any> {
-       suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: P)
-}
-
-interface ResourceFilter {
-       suspend fun PipelineContext<Unit, ApplicationCall>.filterCall()
-}
-
-inline fun <reified T : ResourceHandler> Route.get() {
-       get<T> { resource ->
-               with(resource) {
-                       handleCall()
-               }
-       }
-}
-
-inline fun <reified T : ResourceReceiver<P>, reified P : Any> Route.post() {
-       post<T, P> { resource, payload ->
-               with(resource) { handleCall(payload) }
-       }
-}
-
-inline fun <reified T : ResourceReceiver<P>, reified P : MultiPartPayload> Route.postMultipart() {
-       post<T> { resource ->
-               with(resource) { handleCall(payloadProcessor<P>().process(call.receiveMultipart())) }
-       }
-}
-
-abstract class KeyedEnumSerializer<E : Enum<E>>(val entries: EnumEntries<E>, val getKey: (E) -> String? = { it.name }) : KSerializer<E> {
-       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KeyedEnumSerializer<${entries.first()::class.qualifiedName}>", PrimitiveKind.STRING)
-       
-       private val inner = String.serializer().nullable
-       private val keyMap = entries.associateBy { getKey(it)?.lowercase() }
-       private val default = keyMap[null] ?: entries.first()
-       
-       init {
-               assert(keyMap.size == entries.size)
-       }
-       
-       override fun serialize(encoder: Encoder, value: E) {
-               inner.serialize(encoder, getKey(value))
-       }
-       
-       override fun deserialize(decoder: Decoder): E {
-               return keyMap[inner.deserialize(decoder)?.lowercase()] ?: default
-       }
-}
-
-class FormUrlEncodedFormat(private val resourcesFormat: ResourcesFormat) : StringFormat {
-       override val serializersModule: SerializersModule = resourcesFormat.serializersModule
-       
-       override fun <T> encodeToString(serializer: SerializationStrategy<T>, value: T): String {
-               return resourcesFormat.encodeToParameters(serializer as KSerializer<T>, value).formUrlEncode()
-       }
-       
-       override fun <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
-               return resourcesFormat.decodeFromParameters(deserializer as KSerializer<T>, string.replace("+", "%20").parseUrlEncodedParameters())
-       }
-}
-
-inline fun <reified T : Any> Application.href(resource: T, hash: String? = null): String = URLBuilder().also { href(resource, it) }.build().fullPath + hash?.let { "#$it" }.orEmpty()
-inline fun <reified T : Any> ApplicationCall.href(resource: T, hash: String? = null) = application.href(resource, hash)
-inline fun <reified T : Any> PipelineContext<Unit, ApplicationCall>.href(resource: T, hash: String? = null) = application.href(resource, hash)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_multipart.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_multipart.kt
deleted file mode 100644 (file)
index 51ac7b6..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-package info.mechyrdia.route
-
-import io.ktor.http.content.*
-import kotlin.reflect.full.companionObjectInstance
-
-interface MultiPartPayload : AutoCloseable {
-       val payload: List<PartData>
-       
-       override fun close() {
-               for (data in payload)
-                       data.dispose()
-       }
-}
-
-interface MultiPartPayloadProcessor<P : MultiPartPayload> {
-       suspend fun process(data: MultiPartData): P
-}
-
-inline fun <reified P : MultiPartPayload> payloadProcessor(): MultiPartPayloadProcessor<P> {
-       @Suppress("UNCHECKED_CAST")
-       return P::class.companionObjectInstance as MultiPartPayloadProcessor<P>
-}
-
-data class CsrfProtectedMultiPartPayload(
-       override val csrfToken: String? = null,
-       override val payload: List<PartData>
-) : CsrfProtectedResourcePayload, MultiPartPayload {
-       companion object : MultiPartPayloadProcessor<CsrfProtectedMultiPartPayload> {
-               override suspend fun process(data: MultiPartData): CsrfProtectedMultiPartPayload {
-                       var csrfToken: String? = null
-                       val payload = mutableListOf<PartData>()
-                       
-                       data.forEachPart { part ->
-                               if (part is PartData.FormItem && part.name == "csrfToken")
-                                       csrfToken = part.value
-                               else payload.add(part)
-                       }
-                       
-                       return CsrfProtectedMultiPartPayload(csrfToken, payload)
-               }
-       }
-}
-
-data class PlainMultiPartPayload(
-       override val payload: List<PartData>
-) : MultiPartPayload {
-       companion object : MultiPartPayloadProcessor<PlainMultiPartPayload> {
-               override suspend fun process(data: MultiPartData): PlainMultiPartPayload {
-                       return PlainMultiPartPayload(data.readAllParts())
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt
deleted file mode 100644 (file)
index f1a8d85..0000000
+++ /dev/null
@@ -1,450 +0,0 @@
-package info.mechyrdia.route
-
-import info.mechyrdia.auth.loginPage
-import info.mechyrdia.auth.loginRoute
-import info.mechyrdia.auth.logoutRoute
-import info.mechyrdia.data.*
-import info.mechyrdia.lore.*
-import io.ktor.http.*
-import io.ktor.http.content.*
-import io.ktor.resources.*
-import io.ktor.server.application.*
-import io.ktor.server.html.*
-import io.ktor.server.plugins.*
-import io.ktor.server.response.*
-import io.ktor.util.*
-import io.ktor.util.pipeline.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.withContext
-
-val ErrorMessageAttributeKey = AttributeKey<String>("ErrorMessage")
-
-@Resource("/")
-class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
-       override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-               error?.let { call.attributes.put(ErrorMessageAttributeKey, it) }
-       }
-       
-       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-               filterCall()
-               call.respondHtml(HttpStatusCode.OK, call.loreIntroPage())
-       }
-       
-       @Resource("assets/{path...}")
-       class AssetFile(val path: List<String>, val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondAsset(StoragePath.assetDir / path)
-               }
-       }
-       
-       @Resource("lore/{path...}")
-       class LorePage(val path: List<String>, val format: LoreArticleFormat = LoreArticleFormat.HTML, val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondHtml(HttpStatusCode.OK, call.loreArticlePage(path, format))
-               }
-       }
-       
-       @Resource("map")
-       class GalaxyMap(val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondStoredFile(call.galaxyMapPage())
-               }
-       }
-       
-       @Resource("quote")
-       class RandomQuote(val format: QuoteFormat = QuoteFormat.HTML, val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       with(format) { call.respondQuote(randomQuote()) }
-               }
-       }
-       
-       @Resource("robots.txt")
-       class RobotsTxt(val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondStoredFile(StoragePath.Root / "robots.txt")
-               }
-       }
-       
-       @Resource("sitemap.xml")
-       class SitemapXml(val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondText(buildString { generateSitemap() }, ContentType.Application.Xml)
-               }
-       }
-       
-       @Resource("edits.rss")
-       class RecentEditsRss(val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondText(buildString { generateRecentPageEdits() }, ContentType.Application.Rss)
-               }
-       }
-       
-       @Resource("comments.rss")
-       class RecentCommentsRss(val limit: Int?, val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondText(buildString(call.recentCommentsRssFeedGenerator(limit ?: 10)), ContentType.Application.Rss)
-               }
-       }
-       
-       @Resource("preferences")
-       class ClientPreferences(val root: Root = Root()) : ResourceHandler {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       with(root) { filterCall() }
-                       
-                       call.respondHtml(HttpStatusCode.OK, call.clientSettingsPage())
-               }
-       }
-       
-       @Resource("auth")
-       class Auth(val root: Root = Root()) : ResourceFilter {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-                       with(root) { filterCall() }
-               }
-               
-               @Resource("login")
-               class LoginPage(val auth: Auth = Auth()) : ResourceHandler {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                               with(auth) { filterCall() }
-                               
-                               call.respondHtml(HttpStatusCode.OK, call.loginPage())
-                       }
-               }
-               
-               @Resource("login")
-               class LoginPost(val auth: Auth = Auth()) : ResourceReceiver<LoginPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: LoginPayload) {
-                               with(auth) { filterCall() }
-                               with(payload) { call.verifyCsrfToken() }
-                               
-                               call.loginRoute(payload.nation, payload.checksum, payload.token)
-                       }
-               }
-               
-               @Resource("logout")
-               class LogoutPost(val auth: Auth = Auth()) : ResourceReceiver<LogoutPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: LogoutPayload) {
-                               with(auth) { filterCall() }
-                               with(payload) { call.verifyCsrfToken() }
-                               
-                               call.logoutRoute()
-                       }
-               }
-       }
-       
-       @Resource("comment")
-       class Comments(val root: Root = Root()) : ResourceFilter {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-                       with(root) { filterCall() }
-               }
-               
-               @Resource("help")
-               class HelpPage(val comments: Comments = Comments()) : ResourceHandler {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                               with(comments) { filterCall() }
-                               
-                               call.respondHtml(HttpStatusCode.OK, call.commentHelpPage())
-                       }
-               }
-               
-               @Resource("recent")
-               class RecentPage(val limit: Int? = null, val comments: Comments = Comments()) : ResourceHandler {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                               with(comments) { filterCall() }
-                               
-                               call.respondHtml(HttpStatusCode.OK, call.recentCommentsPage(limit))
-                       }
-               }
-               
-               @Resource("new/{path...}")
-               class NewPost(val path: List<String>, val comments: Comments = Comments()) : ResourceReceiver<NewCommentPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: NewCommentPayload) {
-                               with(comments) { filterCall() }
-                               with(payload) { call.verifyCsrfToken() }
-                               
-                               call.newCommentRoute(path, payload.comment)
-                       }
-               }
-               
-               @Resource("view/{id}")
-               class ViewPage(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceHandler {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                               with(comments) { filterCall() }
-                               
-                               call.viewCommentRoute(id)
-                       }
-               }
-               
-               @Resource("edit/{id}")
-               class EditPost(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceReceiver<EditCommentPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: EditCommentPayload) {
-                               with(comments) { filterCall() }
-                               with(payload) { call.verifyCsrfToken() }
-                               
-                               call.editCommentRoute(id, payload.comment)
-                       }
-               }
-               
-               @Resource("delete/{id}")
-               class DeleteConfirmPage(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceHandler {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                               with(comments) { filterCall() }
-                               
-                               call.respondHtml(HttpStatusCode.OK, call.deleteCommentPage(id))
-                       }
-               }
-               
-               @Resource("delete/{id}")
-               class DeleteConfirmPost(val id: Id<Comment>, val comments: Comments = Comments()) : ResourceReceiver<DeleteCommentPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: DeleteCommentPayload) {
-                               with(comments) { filterCall() }
-                               with(payload) { call.verifyCsrfToken() }
-                               
-                               call.deleteCommentRoute(id)
-                       }
-               }
-       }
-       
-       @Resource("user")
-       class User(val root: Root = Root()) : ResourceHandler, ResourceFilter {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-                       with(root) { filterCall() }
-               }
-               
-               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                       filterCall()
-                       call.currentUserPage()
-               }
-               
-               @Resource("{id}")
-               class ById(val id: Id<NationData>, val user: User = User()) : ResourceHandler {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                               with(user) { filterCall() }
-                               
-                               call.respondHtml(HttpStatusCode.OK, call.userPage(id))
-                       }
-               }
-       }
-       
-       @Resource("admin")
-       class Admin(val root: Root = Root()) : ResourceFilter {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-                       with(root) { filterCall() }
-                       call.ownerNationOnly()
-               }
-               
-               @Resource("ban/{id}")
-               class Ban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminBanUserPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminBanUserPayload) {
-                               with(admin) { filterCall() }
-                               with(payload) { call.verifyCsrfToken() }
-                               
-                               call.adminBanUserRoute(id)
-                       }
-               }
-               
-               @Resource("unban/{id}")
-               class Unban(val id: Id<NationData>, val admin: Admin = Admin()) : ResourceReceiver<AdminUnbanUserPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminUnbanUserPayload) {
-                               with(admin) { filterCall() }
-                               with(payload) { call.verifyCsrfToken() }
-                               
-                               call.adminUnbanUserRoute(id)
-                       }
-               }
-               
-               @Resource("vfs")
-               class Vfs(val admin: Admin = Admin()) : ResourceFilter {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-                               with(admin) { filterCall() }
-                       }
-                       
-                       @Resource("inline/{path...}")
-                       class Inline(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                                       with(vfs) { filterCall() }
-                                       
-                                       call.response.header(HttpHeaders.ContentDisposition, "inline")
-                                       call.adminPreviewFile(StoragePath(path))
-                               }
-                       }
-                       
-                       @Resource("download/{path...}")
-                       class Download(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                                       with(vfs) { filterCall() }
-                                       
-                                       call.response.header(HttpHeaders.ContentDisposition, "attachment; filename=\"${path.last()}\"")
-                                       call.adminPreviewFile(StoragePath(path))
-                               }
-                       }
-                       
-                       @Resource("view/{path...}")
-                       class View(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                                       with(vfs) { filterCall() }
-                                       
-                                       call.respondHtml(HttpStatusCode.OK, call.adminViewVfs(StoragePath(path)))
-                               }
-                       }
-                       
-                       @Resource("copy/{path...}")
-                       class CopyPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                                       with(vfs) { filterCall() }
-                                       
-                                       call.respondHtml(HttpStatusCode.OK, call.adminShowCopyFile(StoragePath(path)))
-                               }
-                       }
-                       
-                       @Resource("copy/{path...}")
-                       class CopyPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsCopyFilePayload> {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsCopyFilePayload) {
-                                       with(vfs) { filterCall() }
-                                       
-                                       call.adminDoCopyFile(StoragePath(payload.from), StoragePath(path))
-                               }
-                       }
-                       
-                       @Resource("upload/{path...}")
-                       class Upload(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<CsrfProtectedMultiPartPayload> {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: CsrfProtectedMultiPartPayload) {
-                                       with(vfs) { filterCall() }
-                                       with(payload) { call.verifyCsrfToken() }
-                                       
-                                       val fileItem = payload.payload.filterIsInstance<PartData.FileItem>().singleOrNull()
-                                               ?: throw MissingRequestParameterException("file")
-                                       
-                                       call.adminUploadFile(StoragePath(path), fileItem)
-                               }
-                       }
-                       
-                       @Resource("overwrite/{path...}")
-                       class Overwrite(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<CsrfProtectedMultiPartPayload> {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: CsrfProtectedMultiPartPayload) {
-                                       with(vfs) { filterCall() }
-                                       with(payload) { call.verifyCsrfToken() }
-                                       
-                                       val fileItem = payload.payload.filterIsInstance<PartData.FileItem>().singleOrNull()
-                                               ?: throw MissingRequestParameterException("file")
-                                       
-                                       call.adminOverwriteFile(StoragePath(path), fileItem)
-                               }
-                       }
-                       
-                       @Resource("delete/{path...}")
-                       class DeleteConfirmPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                                       with(vfs) { filterCall() }
-                                       
-                                       call.adminConfirmDeleteFile(StoragePath(path))
-                               }
-                       }
-                       
-                       @Resource("delete/{path...}")
-                       class DeleteConfirmPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsDeleteFilePayload> {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsDeleteFilePayload) {
-                                       with(vfs) { filterCall() }
-                                       with(payload) { call.verifyCsrfToken() }
-                                       
-                                       call.adminDeleteFile(StoragePath(path))
-                               }
-                       }
-                       
-                       @Resource("mkdir/{path...}")
-                       class MkDir(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsMkDirPayload> {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsMkDirPayload) {
-                                       with(vfs) { filterCall() }
-                                       with(payload) { call.verifyCsrfToken() }
-                                       
-                                       call.adminMakeDirectory(StoragePath(path), payload.directory)
-                               }
-                       }
-                       
-                       @Resource("rmdir/{path...}")
-                       class RmDirConfirmPage(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceHandler {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
-                                       with(vfs) { filterCall() }
-                                       
-                                       call.adminConfirmRemoveDirectory(StoragePath(path))
-                               }
-                       }
-                       
-                       @Resource("rmdir/{path...}")
-                       class RmDirConfirmPost(val path: List<String>, val vfs: Vfs = Vfs()) : ResourceReceiver<AdminVfsRmDirPayload> {
-                               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: AdminVfsRmDirPayload) {
-                                       with(vfs) { filterCall() }
-                                       with(payload) { call.verifyCsrfToken() }
-                                       
-                                       call.adminRemoveDirectory(StoragePath(path))
-                               }
-                       }
-               }
-       }
-       
-       @Resource("utils")
-       class Utils(val root: Root = Root()) : ResourceFilter {
-               override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
-                       with(root) { filterCall() }
-                       
-                       delay(250L)
-               }
-               
-               @Resource("mechyrdia-sans")
-               class MechyrdiaSans(val utils: Utils = Utils()) : ResourceReceiver<MechyrdiaSansPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: MechyrdiaSansPayload) {
-                               with(utils) { filterCall() }
-                               
-                               call.respondText(withContext(Dispatchers.Default) {
-                                       MechyrdiaSansFont.renderTextToSvg(payload.lines.joinToString(separator = "\n") { it.trim() }, payload.bold, payload.italic, payload.align)
-                               }, ContentType.Image.SVG)
-                       }
-               }
-               
-               @Resource("tylan-lang")
-               class TylanLanguage(val utils: Utils = Utils()) : ResourceReceiver<TylanLanguagePayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: TylanLanguagePayload) {
-                               with(utils) { filterCall() }
-                               
-                               call.respondText(TylanAlphabetFont.tylanToFontAlphabet(payload.lines.joinToString(separator = "\n")))
-                       }
-               }
-               
-               @Resource("pokhwal-lang")
-               class PokhwalishLanguage(val utils: Utils = Utils()) : ResourceReceiver<PokhwalishLanguagePayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: PokhwalishLanguagePayload) {
-                               with(utils) { filterCall() }
-                               
-                               call.respondText(PokhwalishAlphabetFont.pokhwalToFontAlphabet(payload.lines.joinToString(separator = "\n")))
-                       }
-               }
-               
-               @Resource("preview-comment")
-               class PreviewComment(val utils: Utils = Utils()) : ResourceReceiver<PreviewCommentPayload> {
-                       override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: PreviewCommentPayload) {
-                               with(utils) { filterCall() }
-                               
-                               call.respondText(
-                                       text = payload.lines.joinToString(separator = "\n").parseAs(ParserTree::toCommentHtml).toFragment(),
-                                       contentType = ContentType.Text.Html
-                               )
-                       }
-               }
-       }
-}