Add raw factbook rendering
authorLanius Trolling <lanius@laniustrolling.dev>
Thu, 7 Mar 2024 16:52:50 +0000 (11:52 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Thu, 7 Mar 2024 17:38:05 +0000 (12:38 -0500)
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt
src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_error.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_quote.kt

index 9a8d3793a13925254d12b4122dac03119a6e3207..12738f51b3833c90db17b936db71b393e168a6aa 100644 (file)
@@ -148,6 +148,10 @@ fun Application.factbooks() {
                        call.respondHtml(HttpStatusCode.OK, call.loreArticlePage())
                }
                
+               get("/lore.raw") {
+                       call.respondHtml(HttpStatusCode.OK, call.loreRawArticlePage(""))
+               }
+               
                staticFiles("/assets", File(Configuration.CurrentConfiguration.assetDir), index = null) {
                        enableAutoHeadResponse()
                }
@@ -162,6 +166,10 @@ fun Application.factbooks() {
                        with(call) { respondHtml(HttpStatusCode.OK, randomQuote().toHtml("Random Quote")) }
                }
                
+               get("/quote.raw") {
+                       with(call) { respondHtml(HttpStatusCode.OK, randomQuote().toRawHtml("Random Quote")) }
+               }
+               
                get("/quote.json") {
                        call.respondText(randomQuote().toJson(), ContentType.Application.Json)
                }
index 5bac26d5fab5d45670dcf7a618e9e87e7b5e30d7..14643cc7a169115f26ba894b25ba5e1c42ae0fc9 100644 (file)
@@ -31,15 +31,15 @@ val ArticleNode.isViewable: Boolean
 val File.isViewable: Boolean
        get() = name.isViewable
 
-fun List<ArticleNode>.renderInto(list: UL, base: String? = null) {
+fun List<ArticleNode>.renderInto(list: UL, base: String? = null, suffix: String = "") {
        val prefix by lazy(LazyThreadSafetyMode.NONE) { base?.let { "$it/" } ?: "" }
        for (node in this) {
                if (node.isViewable)
                        list.li {
-                               a(href = "/lore/$prefix${node.name}") { +node.name }
+                               a(href = "/lore/$prefix${node.name}$suffix") { +node.name }
                                if (node.subNodes.isNotEmpty())
                                        ul {
-                                               node.subNodes.renderInto(this, "$prefix${node.name}")
+                                               node.subNodes.renderInto(this, "$prefix${node.name}", suffix)
                                        }
                        }
        }
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt
new file mode 100644 (file)
index 0000000..1cc340b
--- /dev/null
@@ -0,0 +1,188 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.Configuration
+import io.ktor.util.*
+import java.io.File
+
+fun String.toRawLink() = substringBeforeLast('#') + ".raw"
+
+enum class TextParserRawPageTag(val type: TextParserTagType<Unit>) {
+       IPA(
+               TextParserTagType.Direct(
+                       false,
+                       { _, _ -> "" },
+                       { _ -> "" },
+               )
+       ),
+       CODE(
+               TextParserTagType.Direct(
+                       false,
+                       { _, _ -> "<span style='font-family: monospace'>" },
+                       { _ -> "</span>" },
+               )
+       ),
+       CODE_BLOCK(
+               TextParserTagType.Direct(
+                       false,
+                       { _, _ -> "<div style='font-family: monospace'><pre>" },
+                       { _ -> "</pre></div>" },
+               )
+       ),
+       H1(
+               TextParserTagType.Direct(
+                       true,
+                       { _, _ -> "<h1>" },
+                       { _ -> "</h1>" }
+               )
+       ),
+       H2(
+               TextParserTagType.Direct(
+                       true,
+                       { _, _ -> "<h2>" },
+                       { _ -> "</h2>" }
+               )
+       ),
+       H3(
+               TextParserTagType.Direct(
+                       true,
+                       { _, _ -> "<h3>" },
+                       { _ -> "</h3>" }
+               )
+       ),
+       H4(
+               TextParserTagType.Direct(
+                       true,
+                       { _, _ -> "<h4>" },
+                       { _ -> "</h4>" }
+               )
+       ),
+       H5(
+               TextParserTagType.Direct(
+                       true,
+                       { _, _ -> "<h5>" },
+                       { _ -> "</h5>" }
+               )
+       ),
+       H6(
+               TextParserTagType.Direct(
+                       true,
+                       { _, _ -> "<h6>" },
+                       { _ -> "</h6>" }
+               )
+       ),
+       ASIDE(
+               TextParserTagType.Direct(
+                       true,
+                       { tagParam, _ ->
+                               val floats = setOf("left", "right")
+                               val float = tagParam?.takeIf { it in floats } ?: "right"
+                               "<div style='float: $float; max-width: 50vw'>"
+                       },
+                       { _ -> "</div>" },
+               )
+       ),
+       IMAGE(
+               TextParserTagType.Indirect(false) { tagParam, content, _ ->
+                       val imageUrl = sanitizeLink(content)
+                       
+                       val (width, height) = getSizeParam(tagParam)
+                       
+                       if (imageUrl.endsWith(".svg"))
+                               File(Configuration.CurrentConfiguration.assetDir, "images")
+                                       .combineSafe(imageUrl)
+                                       .readText()
+                                       .replace("<svg", "<svg${getRawImageSizeAttributes(width, height)}")
+                       else
+                               "<img${getRawImageSizeAttributes(width, height)} src='/assets/images/$imageUrl'/>"
+               }
+       ),
+       MODEL(
+               TextParserTagType.Indirect(false) { _, _, _ ->
+                       "Unfortunately, raw view does not support interactive 3D model views"
+               }
+       ),
+       QUIZ(
+               TextParserTagType.Indirect(true) { _, _, _ ->
+                       "<p>Unfortunately, raw view does not support interactive quizzes</p>"
+               }
+       ),
+       TABLE(
+               TextParserTagType.Direct(
+                       true,
+                       { _, _ -> "<table style='width: 100%; table-layout: fixed'>" },
+                       { _ -> "</table>" },
+               )
+       ),
+       TD(
+               TextParserTagType.Direct(
+                       true,
+                       { tagParam, _ ->
+                               val (width, height) = getSizeParam(tagParam)
+                               val sizeAttrs = getTableSizeAttributes(width, height)
+                               
+                               "<td$sizeAttrs style='border: 1px solid #555'>"
+                       },
+                       { _ -> "</td>" },
+               )
+       ),
+       TH(
+               TextParserTagType.Direct(
+                       true,
+                       { tagParam, _ ->
+                               val (width, height) = getSizeParam(tagParam)
+                               val sizeAttrs = getTableSizeAttributes(width, height)
+                               
+                               "<th$sizeAttrs style='border: 1px solid #222'>"
+                       },
+                       { _ -> "</th>" },
+               )
+       ),
+       LINK(
+               TextParserTagType.Direct(
+                       false,
+                       { tagParam, _ ->
+                               val param = tagParam?.let { TextParserState.censorText(it) }
+                               val url = param?.let { if (it.startsWith('/')) "/lore$it" else "./$it" }
+                               val attr = url?.let { " href=\"${it.toRawLink()}\"" } ?: ""
+                               
+                               "<a$attr>"
+                       },
+                       { _ -> "</a>" },
+               )
+       ),
+       REDIRECT(
+               TextParserTagType.Indirect(false) { _, content, _ ->
+                       val target = TextParserState.censorText(content)
+                       val url = if (target.startsWith('/')) "/lore$target" else "./$target"
+                       
+                       "<a href='${url.toRawLink()}'>Click here for a manual redirect</a>"
+               }
+       ),
+       LANG(
+               TextParserTagType.Direct(
+                       false,
+                       { _, _ -> "<span style='font-style: italic'>" },
+                       { _ -> "</span>" }
+               )
+       ),
+       ALPHABET(
+               TextParserTagType.Indirect(true) { _, _, _ ->
+                       "<p>Unfortunately, raw view does not support interactive conscript previews</p>"
+               }
+       ),
+       VOCAB(
+               TextParserTagType.Indirect(true) { _, _, _ ->
+                       "<p>Unfortunately, raw view does not support interactive conlang dictionaries</p>"
+               }
+       ),
+       ;
+       
+       companion object {
+               val asTags: TextParserTags<Unit> by lazy {
+                       TextParserFormattingTag.asTags + TextParserTags(entries.associate { it.name to it.type })
+               }
+       }
+}
+
+fun getRawImageSizeStyleValue(width: Int?, height: Int?) = (width?.let { "width: ${it * 0.25}px;" } ?: "") + (height?.let { "height: ${it * 0.25}px;" } ?: "")
+fun getRawImageSizeAttributes(width: Int?, height: Int?) = " style=\"${getRawImageSizeStyleValue(width, height)}\""
index bdc6af802f7d49f1122c5edd457cc903116807a9..4f1598759642d3290fd180765cec33f95646b3a4 100644 (file)
@@ -165,9 +165,9 @@ enum class TextParserFormattingTag(val type: TextParserTagType<Unit>) {
                                val alignments = setOf("left", "center", "right", "justify")
                                val alignment = tagParam?.takeIf { it in alignments }
                                val styleAttr = alignment?.let { " style=\"text-align: $it\"" } ?: ""
-                               "<div$styleAttr>"
+                               "<p$styleAttr>"
                        },
-                       { _ -> "</div>" },
+                       { _ -> "</p>" },
                )
        ),
        ASIDE(
index e782e81172296847caffb23436a30975c1a32994..64bb48744d49f564068eb34e4433410a1c8cbd74 100644 (file)
@@ -169,3 +169,29 @@ fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sideb
                }
        }
 }
+
+fun ApplicationCall.rawPage(pageTitle: String, ogData: OpenGraphData? = null, content: BODY.() -> Unit): HTML.() -> Unit {
+       return {
+               lang = "en"
+               
+               head {
+                       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
+                       }
+               }
+               body {
+                       content()
+               }
+       }
+}
index 49cde50c2dd0aba05c4e8a963e75a2b213d8b6dd..4760549d49ec8e410eb1935d78cd059e6a0ec45a 100644 (file)
@@ -5,55 +5,58 @@ import io.ktor.server.application.*
 import io.ktor.server.request.*
 import kotlinx.html.*
 
-suspend fun ApplicationCall.error400(): HTML.() -> Unit = page("400 Bad Request", standardNavBar()) {
-       section {
-               h1 { +"400 Bad Request" }
-               p { +"The request your browser sent was improperly formatted." }
-       }
+suspend fun ApplicationCall.errorPage(title: String, body: FlowContent.() -> Unit): HTML.() -> Unit {
+       return if (request.path().endsWith(".raw"))
+               rawPage(title) {
+                       body()
+               }
+       else
+               page(title, standardNavBar()) {
+                       section {
+                               body()
+                       }
+               }
 }
 
-suspend fun ApplicationCall.error403(): HTML.() -> Unit = page("403 Forbidden", standardNavBar()) {
-       section {
-               h1 { +"403 Forbidden" }
-               p { +"You are not allowed to do that." }
-       }
+suspend fun ApplicationCall.error400(): HTML.() -> Unit = errorPage("400 Bad Request") {
+       h1 { +"400 Bad Request" }
+       p { +"The request your browser sent was improperly formatted." }
 }
 
-suspend fun ApplicationCall.error403PageExpired(formData: Parameters): HTML.() -> Unit = page("Page Expired", standardNavBar()) {
-       section {
-               h1 { +"Page Expired" }
-               formData["comment"]?.let { commentData ->
-                       p { +"The comment you tried to submit had been preserved here:" }
-                       textArea {
-                               readonly = true
-                               +commentData
-                       }
+suspend fun ApplicationCall.error403(): HTML.() -> Unit = errorPage("403 Forbidden") {
+       h1 { +"403 Forbidden" }
+       p { +"You are not allowed to do that." }
+}
+
+suspend fun ApplicationCall.error403PageExpired(formData: Parameters): HTML.() -> Unit = errorPage("Page Expired") {
+       h1 { +"Page Expired" }
+       formData["comment"]?.let { commentData ->
+               p { +"The comment you tried to submit had been preserved here:" }
+               textArea {
+                       readonly = true
+                       +commentData
                }
-               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."
-                       }
+       }
+       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 = page("404 Not Found", standardNavBar()) {
-       section {
-               h1 { +"404 Not Found" }
-               p {
-                       +"Unfortunately, we could not find what you were looking for. Would you like to "
-                       a(href = "/") { +"return to the index page" }
-                       +"?"
-               }
+suspend fun ApplicationCall.error404(): HTML.() -> Unit = errorPage("404 Not Found") {
+       h1 { +"404 Not Found" }
+       p {
+               +"Unfortunately, we could not find what you were looking for. Would you like to "
+               a(href = "/") { +"return to the index page" }
+               +"?"
        }
 }
 
-suspend fun ApplicationCall.error500(): HTML.() -> Unit = page("500 Internal Error", standardNavBar()) {
-       section {
-               h1 { +"500 Internal Error" }
-               p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." }
-       }
+suspend fun ApplicationCall.error500(): HTML.() -> Unit = errorPage("500 Internal Error") {
+       h1 { +"500 Internal Error" }
+       p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." }
 }
index b6570fc618049b243c88b1be50838679c3c0de1d..ea5fe36d3705450e07e13feff48bdc4ef1333300 100644 (file)
@@ -39,11 +39,87 @@ suspend fun ApplicationCall.loreIntroPage(): HTML.() -> Unit {
        }
 }
 
+private fun FlowContent.breadCrumbs(links: List<Pair<String, String>>) = p {
+       var isNext = false
+       for ((url, text) in links) {
+               if (isNext) {
+                       +Entities.nbsp
+                       +Entities.gt
+                       +Entities.nbsp
+               } else isNext = true
+               
+               a(href = url) { +text }
+       }
+}
+
+fun ApplicationCall.loreRawArticlePage(rawPagePath: String): HTML.() -> Unit {
+       val articleDir = File(Configuration.CurrentConfiguration.articleDir)
+       
+       val pagePath = rawPagePath.removeSuffix(".raw")
+       val pageFile = if (pagePath.isEmpty()) articleDir else articleDir.combineSafe(pagePath)
+       val pageNode = pageFile.toArticleNode()
+       
+       val parentPaths = if (pagePath.isEmpty())
+               emptyList()
+       else {
+               val pathParts = pagePath.split('/').dropLast(1)
+               listOf("/lore.raw" to "Table of Contents") + pathParts.mapIndexed { i, part ->
+                       pathParts.take(i + 1).joinToString(separator = "/", prefix = "/lore/", postfix = ".raw") to part.toFriendlyPageTitle()
+               }
+       }
+       
+       val isValid = pageFile.exists() && pageFile.isViewable
+       
+       if (isValid) {
+               if (pageFile.isDirectory) {
+                       val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: "Table of Contents"
+                       
+                       return rawPage(title) {
+                               breadCrumbs(parentPaths)
+                               h1 { +title }
+                               ul {
+                                       pageNode.subNodes.renderInto(this, pagePath.takeIf { it.isNotEmpty() }, ".raw")
+                               }
+                       }
+               }
+               
+               if (pageFile.isFile) {
+                       val pageTemplate = pageFile.readText()
+                       val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
+                       val pageHtml = TextParserState.parseText(pageMarkup, TextParserRawPageTag.asTags, Unit)
+                       
+                       val pageToC = TableOfContentsBuilder()
+                       TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
+                       
+                       return rawPage(pageToC.toPageTitle(), pageToC.toOpenGraph()) {
+                               breadCrumbs(parentPaths)
+                               unsafe { raw(pageHtml) }
+                       }
+               }
+       }
+       
+       val title = pagePath.substringAfterLast('/').toFriendlyPageTitle()
+       
+       return rawPage(title) {
+               breadCrumbs(parentPaths)
+               h1 { +title }
+               p {
+                       +"This factbook does not exist. Would you like to "
+                       a(href = "/lore.raw") { +"return to the table of contents" }
+                       +"?"
+               }
+       }
+}
+
 suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
        val totalsData = processGuestbook()
        
        val pagePathParts = parameters.getAll("path")!!
        val pagePath = pagePathParts.joinToString("/")
+       
+       if (pagePath.endsWith(".raw"))
+               return loreRawArticlePage(pagePath)
+       
        val pageFile = File(Configuration.CurrentConfiguration.articleDir).combineSafe(pagePath)
        val pageNode = pageFile.toArticleNode()
        
index f7baa6c75ad87eaa3b6774a9e70916a1581b33c0..1602f406a8847697f1814710b7d9630905dc62c0 100644 (file)
@@ -78,3 +78,26 @@ suspend fun Quote.toHtml(title: String): HTML.() -> Unit {
                }
        }
 }
+
+context(ApplicationCall)
+fun Quote.toRawHtml(title: String): HTML.() -> Unit {
+       return rawPage(title) {
+               a { id = "page-top" }
+               h1 { +title }
+               blockQuote {
+                       +quote
+               }
+               p {
+                       style = "align:right"
+                       unsafe { raw("&#x2015;") }
+                       +Entities.nbsp
+                       a(href = fullLink) { +author }
+               }
+               p {
+                       style = "align:center"
+                       a(href = fullLink) {
+                               img(src = fullPortrait, alt = "Image of $author")
+                       }
+               }
+       }
+}