Rewrite BBCode parser
authorLanius Trolling <lanius@laniustrolling.dev>
Fri, 5 Apr 2024 16:06:42 +0000 (12:06 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Fri, 5 Apr 2024 16:16:35 +0000 (12:16 -0400)
17 files changed:
src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt
src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_builder.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_html.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt
src/jvmMain/kotlin/info/mechyrdia/lore/parser_reply.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_toc.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_tree.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/parser_utils.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt
src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt

index 67036733a93e5bacdc67fb56155b30119f12ab08..91372602e284c2072bb4e541a02167fc2dea4b70 100644 (file)
@@ -1,10 +1,7 @@
 package info.mechyrdia.data
 
 import info.mechyrdia.OwnerNationId
-import info.mechyrdia.lore.TextParserCommentTags
-import info.mechyrdia.lore.TextParserState
-import info.mechyrdia.lore.dateTime
-import info.mechyrdia.lore.toFriendlyIndexTitle
+import info.mechyrdia.lore.*
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.href
 import info.mechyrdia.route.installCsrfToken
@@ -26,7 +23,7 @@ data class CommentRenderData(
        val lastEdit: Instant?,
        
        val contentsRaw: String,
-       val contentsHtml: String,
+       val contentsHtml: TagConsumer<*>.() -> Any?,
        
        val replyLinks: List<Id<Comment>>,
 ) {
@@ -35,7 +32,7 @@ data class CommentRenderData(
                        return coroutineScope {
                                comments.map { comment ->
                                        val nationData = nations.getNation(comment.submittedBy)
-                                       val htmlResult = TextParserState.parseText(comment.contents, TextParserCommentTags.asTags, Unit)
+                                       val htmlResult = comment.contents.parseAs(ParserTree::toCommentHtml)
                                        
                                        async {
                                                CommentRenderData(
@@ -86,7 +83,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                }
                
                div(classes = "comment") {
-                       unsafe { raw(comment.contentsHtml) }
+                       +comment.contentsHtml
                        comment.lastEdit?.let { lastEdit ->
                                p {
                                        style = "font-size:0.8em"
index 154464507bb92bc8603f3827ce9b7cc3f8226e38..257cab9c03ca48ebd99ae5d3f6920cb29f42505a 100644 (file)
@@ -415,12 +415,12 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                |[/tr]
                                |[/table]
                        """.trimMargin()
-               val tableDemoHtml = TextParserState.parseText(tableDemoMarkup, TextParserCommentTags.asTags, Unit)
+               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 }
-               unsafe { raw(tableDemoHtml) }
+               +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)]"
                }
@@ -436,7 +436,10 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                        td { +"[url=https://google.com/]Text goes here[/url]" }
                                        td {
                                                +"Creates an "
-                                               a(href = "https://google.com/") { +"HTML link" }
+                                               a(href = "https://google.com/") {
+                                                       rel = "nofollow"
+                                                       +"HTML link"
+                                               }
                                        }
                                }
                                tr {
@@ -493,6 +496,15 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                                }
                                        }
                                }
+                               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/lore/parser.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser.kt
deleted file mode 100644 (file)
index 2b6cf90..0000000
+++ /dev/null
@@ -1,252 +0,0 @@
-package info.mechyrdia.lore
-
-data class TextParserScope<TContext>(
-       val write: StringBuilder,
-       val tags: TextParserTags<TContext>,
-       val ctx: TContext,
-)
-
-class TextParserInternalState {
-       var suppressEndParagraph: Boolean = false
-}
-
-sealed class TextParserState<TContext>(
-       val scope: TextParserScope<TContext>,
-       val insideTags: List<String>,
-       protected val internalState: TextParserInternalState,
-) {
-       abstract fun processCharacter(char: Char): TextParserState<TContext>
-       abstract fun processEndOfText()
-       
-       protected fun appendText(text: String) {
-               if (text.isEmpty()) return
-               
-               scope.write.append(censorText(text))
-               internalState.suppressEndParagraph = false
-       }
-       
-       protected fun appendTextRaw(text: String) {
-               if (text.isEmpty()) return
-               
-               scope.write.append(text)
-               internalState.suppressEndParagraph = false
-       }
-       
-       protected fun nextParagraph() {
-               val newline = if (insideTags.isEmpty()) {
-                       if (internalState.suppressEndParagraph)
-                               "<p>"
-                       else
-                               "</p><p>"
-               } else
-                       "<br/><br/>"
-               
-               appendTextRaw(newline)
-       }
-       
-       protected fun lastParagraph() {
-               if (!internalState.suppressEndParagraph)
-                       appendTextRaw("</p>")
-       }
-       
-       protected fun cancelEndParagraph() {
-               internalState.suppressEndParagraph = true
-       }
-       
-       protected fun cancelStartParagraph() {
-               if (scope.write.endsWith("<p>"))
-                       scope.write.deleteRange(scope.write.length - "<p>".length, scope.write.length)
-       }
-       
-       class Initial<TContext>(scope: TextParserScope<TContext>) : TextParserState<TContext>(scope, listOf(), TextParserInternalState()) {
-               override fun processCharacter(char: Char): TextParserState<TContext> {
-                       return if (char == '[')
-                               OpenTag(scope, "", insideTags, internalState)
-                       else {
-                               appendTextRaw("<p>")
-                               PlainText(scope, "$char", insideTags, internalState)
-                       }
-               }
-               
-               override fun processEndOfText() {
-                       // Do nothing
-               }
-       }
-       
-       class PlainText<TContext>(scope: TextParserScope<TContext>, private val text: String, insideTags: List<String>, internalState: TextParserInternalState) : TextParserState<TContext>(scope, insideTags, internalState) {
-               override fun processCharacter(char: Char): TextParserState<TContext> {
-                       return if (char == '[') {
-                               appendText(text)
-                               OpenTag(scope, "", insideTags, internalState)
-                       } else if (char == '\n' && text.endsWith('\n')) {
-                               appendText(text.removeSuffix("\n"))
-                               
-                               nextParagraph()
-                               
-                               PlainText(scope, "", insideTags, internalState)
-                       } else
-                               PlainText(scope, text + char, insideTags, internalState)
-               }
-               
-               override fun processEndOfText() {
-                       appendText(text.removeSuffix("\n"))
-                       if (text.isNotBlank())
-                               lastParagraph()
-               }
-       }
-       
-       class NoFormatText<TContext>(scope: TextParserScope<TContext>, private val text: String, insideTags: List<String>, internalState: TextParserInternalState) : TextParserState<TContext>(scope, insideTags, internalState) {
-               override fun processCharacter(char: Char): TextParserState<TContext> {
-                       val newText = text + char
-                       return if (newText.endsWith("[/$NO_FORMAT_TAG]")) {
-                               appendText(newText.removeSuffix("[/$NO_FORMAT_TAG]"))
-                               PlainText(scope, "", insideTags, internalState)
-                       } else if (newText.endsWith('\n')) {
-                               appendText(newText.removeSuffix("\n"))
-                               appendTextRaw("<br/><br/>")
-                               NoFormatText(scope, "", insideTags, internalState)
-                       } else
-                               NoFormatText(scope, newText, insideTags, internalState)
-               }
-               
-               override fun processEndOfText() {
-                       appendText(text.removeSuffix("\n"))
-                       if (text.isNotBlank())
-                               lastParagraph()
-               }
-       }
-       
-       class InsideIndirectTag<TContext>(scope: TextParserScope<TContext>, private val tagName: String, private val tagType: TextParserTagType.Indirect<TContext>, private val param: String?, private val text: String, insideTags: List<String>, internalState: TextParserInternalState) : TextParserState<TContext>(scope, insideTags, internalState) {
-               override fun processCharacter(char: Char): TextParserState<TContext> {
-                       val newText = text + char
-                       return if (newText.endsWith("[/$tagName]")) {
-                               appendTextRaw(tagType.process(param, censorText(newText.removeSuffix("[/$tagName]")), scope.ctx))
-                               
-                               if (tagType.isBlock)
-                                       cancelEndParagraph()
-                               
-                               PlainText(scope, "", insideTags, internalState)
-                       } else
-                               InsideIndirectTag(scope, tagName, tagType, param, newText, insideTags, internalState)
-               }
-               
-               override fun processEndOfText() {
-                       appendTextRaw(tagType.process(param, censorText(text), scope.ctx))
-                       if (text.isNotBlank())
-                               lastParagraph()
-               }
-       }
-       
-       class OpenTag<TContext>(scope: TextParserScope<TContext>, private val tag: String, insideTags: List<String>, internalState: TextParserInternalState) : TextParserState<TContext>(scope, insideTags, internalState) {
-               override fun processCharacter(char: Char): TextParserState<TContext> {
-                       return if (char == ']') {
-                               if (tag.equals(NO_FORMAT_TAG, ignoreCase = true))
-                                       NoFormatText(scope, "", insideTags, internalState)
-                               else {
-                                       val tagType = scope.tags[tag]
-                                       if (tagType?.isBlock == true)
-                                               cancelStartParagraph()
-                                       
-                                       when (tagType) {
-                                               is TextParserTagType.Direct<TContext> -> {
-                                                       appendTextRaw(tagType.begin(null, scope.ctx))
-                                                       PlainText(scope, "", insideTags + tag, internalState)
-                                               }
-                                               
-                                               is TextParserTagType.Indirect<TContext> -> InsideIndirectTag(scope, tag, tagType, null, "", insideTags, internalState)
-                                               
-                                               else -> PlainText(scope, "[$tag]", insideTags, internalState)
-                                       }
-                               }
-                       } else if (char == '/' && tag == "")
-                               CloseTag(scope, tag, insideTags, internalState)
-                       else if (char == '=' && tag != "")
-                               TagParam(scope, tag, "", insideTags, internalState)
-                       else
-                               OpenTag(scope, tag + char, insideTags, internalState)
-               }
-               
-               override fun processEndOfText() {
-                       appendText("[$tag")
-                       lastParagraph()
-               }
-       }
-       
-       class TagParam<TContext>(scope: TextParserScope<TContext>, private val tag: String, private val param: String, insideTags: List<String>, internalState: TextParserInternalState) : TextParserState<TContext>(scope, insideTags, internalState) {
-               override fun processCharacter(char: Char): TextParserState<TContext> {
-                       return if (char == ']') {
-                               val tagType = scope.tags[tag]
-                               if (tagType?.isBlock == true)
-                                       cancelStartParagraph()
-                               
-                               when (tagType) {
-                                       is TextParserTagType.Direct<TContext> -> {
-                                               appendTextRaw(tagType.begin(param, scope.ctx))
-                                               PlainText(scope, "", insideTags + tag, internalState)
-                                       }
-                                       
-                                       is TextParserTagType.Indirect<TContext> -> InsideIndirectTag(scope, tag, tagType, param, "", insideTags, internalState)
-                                       
-                                       else -> PlainText(scope, "[$tag=$param]", insideTags, internalState)
-                               }
-                       } else
-                               TagParam(scope, tag, param + char, insideTags, internalState)
-               }
-               
-               override fun processEndOfText() {
-                       appendText("[$tag=$param")
-                       lastParagraph()
-               }
-       }
-       
-       class CloseTag<TContext>(scope: TextParserScope<TContext>, private val tag: String, insideTags: List<String>, internalState: TextParserInternalState) : TextParserState<TContext>(scope, insideTags, internalState) {
-               override fun processCharacter(char: Char): TextParserState<TContext> {
-                       return if (char == ']') {
-                               val tagType = scope.tags[tag]
-                               val nextState = if (tagType is TextParserTagType.Direct<TContext> && insideTags.lastOrNull() == tag) {
-                                       appendTextRaw(tagType.end(scope.ctx))
-                                       PlainText(scope, "", insideTags.dropLast(1), internalState)
-                               } else {
-                                       appendText("[/$tag]")
-                                       PlainText(scope, "", insideTags, internalState)
-                               }
-                               
-                               if (tagType?.isBlock == true)
-                                       cancelEndParagraph()
-                               
-                               nextState
-                       } else CloseTag(scope, tag + char, insideTags, internalState)
-               }
-               
-               override fun processEndOfText() {
-                       appendText("[/$tag")
-                       lastParagraph()
-               }
-       }
-       
-       companion object {
-               const val NO_FORMAT_TAG = "noformat"
-               
-               fun censorText(uncensored: String) = uncensored
-                       .replace("&", "&amp;")
-                       .replace("<", "&lt;")
-                       .replace(">", "&gt;")
-                       .replace("\"", "&quot;")
-               
-               fun uncensorText(censored: String) = censored
-                       .replace("&quot;", "\"")
-                       .replace("&lt;", "<")
-                       .replace("&gt;", ">")
-                       .replace("&amp;", "&")
-               
-               fun <TContext> parseText(text: String, tags: TextParserTags<TContext>, context: TContext): String {
-                       val builder = StringBuilder()
-                       val fixedText = text.replace("\r\n", "\n").replace('\r', '\n')
-                       fixedText
-                               .fold<TextParserState<TContext>>(Initial(TextParserScope(builder, tags, context))) { state, char ->
-                                       state.processCharacter(char)
-                               }.processEndOfText()
-                       return "$builder"
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_builder.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_builder.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/parser_html.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_html.kt
new file mode 100644 (file)
index 0000000..ecbb623
--- /dev/null
@@ -0,0 +1,700 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.Configuration
+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 java.io.File
+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() = buildString {
+       val builder = appendHTML()
+       with(HtmlLexerTagConsumer(builder)) { this@toFragment() }
+       builder.finalize()
+}
+
+class HtmlLexerTagConsumer(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
+       }
+}
+
+context(C)
+operator fun <T, C : TagConsumer<T>> String.unaryPlus() = onTagContent(this)
+
+context(C)
+operator fun <T, C : TagConsumer<T>> Entities.unaryPlus() = onTagContentEntity(this)
+
+context(C)
+fun <T, C : TagConsumer<T>> C.unsafe(block: Unsafe.() -> Unit) = onTagContentUnsafe(block)
+
+fun ParserTree.shouldSplitSections(): Boolean = firstOrNull()?.let {
+       it is ParserTreeNode.Tag && it.tag.lowercase() == "h1"
+} == true
+
+fun ParserTree.splitSections(): List<ParserTree> = splitBefore {
+       it is ParserTreeNode.Tag && it.tag.lowercase() == "h2"
+}
+
+fun ParserTreeNode.isParagraph(inlineTags: Set<String>): Boolean = when (this) {
+       is ParserTreeNode.Text -> true
+       ParserTreeNode.LineBreak -> false
+       is ParserTreeNode.Tag -> tag.lowercase() in 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.filter { it !is ParserTreeNode.Text || it.text.isNotBlank() }.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()
+       }
+}
+
+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, 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()
+                       }
+                       script { unsafe { +"window.checkRedirectTarget(\"${anchorId.orEmpty()}\");" } }
+               }
+       }
+}
+
+fun processColor(param: String?): Map<String, String> = param?.removePrefix("#")?.let { colorRaw ->
+       when (colorRaw.length) {
+               6 -> colorRaw
+               3 -> {
+                       val (r, g, b) = colorRaw.toCharArray()
+                       "$r$r$g$g$b$b"
+               }
+               
+               else -> null
+       }
+}?.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()}"
+
+fun processAnchor(param: String?): Map<String, String> = param?.let { mapOf("id" to it, "name" to it) }.orEmpty()
+
+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())),
+       
+       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, tagCreator = TagConsumer<*>::div.toTagCreator())),
+       ASIDE(HtmlTagLexerTag(attributes = ::processFloat, 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)
+               
+               if (url.endsWith(".svg")) {
+                       val svg = File(Configuration.CurrentConfiguration.assetDir, "images")
+                               .combineSafe(url)
+                               .readText()
+                               .replaceFirst("<svg", "<svg${getImageSizeAttributes(width, height)}")
+                       
+                       ({ unsafe { +svg } })
+               } else {
+                       val styleValue = getImageSizeStyleValue(width, height)
+                       
+                       ({
+                               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())),
+       
+       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 { +"Tylan 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 { +"Tylan 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 { +"Tylan 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),
+       
+       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)
+                       ({ +"[reply]$content[/reply]" })
+               else
+                       ({
+                               a(href = "/comment/view/$id") {
+                                       rel = "ugc"
+                                       +">>$id"
+                               }
+                       })
+       }),
+       
+       QUOTE(FactbookFormattingTag.BLOCKQUOTE.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
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
+}
index 91d60bcacc0d45c675e6b5069b8b082838f57e15..f2376d2688f6684bec96c3c8da3f552e69207e30 100644 (file)
 package info.mechyrdia.lore
 
-private val plainTextFormattingTag = TextParserTagType.Direct<Unit>(
-       false,
-       { _, _ -> "" },
-       { "" },
-)
+typealias PlainTextBuilderContext = Unit
+typealias PlainTextBuilderSubject = String
 
-private val spacedFormattingTag = TextParserTagType.Direct<Unit>(
-       true,
-       { _, _ -> " " },
-       { " " },
-)
-
-private val embeddedFormattingTag = TextParserTagType.Indirect<Unit>(false) { _, _, _ -> "" }
-private val embeddedBlockFormattingTag = TextParserTagType.Indirect<Unit>(true) { _, _, _ -> "" }
+enum class PlainTextTagBehavior {
+       PASS_THROUGH,
+       PASS_THROUGH_SPACED,
+       ABSORB
+}
 
-enum class TextParserFormattingTagPlainText(val type: TextParserTagType<Unit>) {
-       // Basic formatting
-       B(plainTextFormattingTag),
-       I(plainTextFormattingTag),
-       U(plainTextFormattingTag),
-       S(plainTextFormattingTag),
-       SUP(plainTextFormattingTag),
-       SUB(plainTextFormattingTag),
-       COLOR(plainTextFormattingTag),
-       IPA(plainTextFormattingTag),
-       CODE(plainTextFormattingTag),
-       H1(plainTextFormattingTag),
-       H2(plainTextFormattingTag),
-       H3(plainTextFormattingTag),
-       H4(plainTextFormattingTag),
-       H5(plainTextFormattingTag),
-       H6(plainTextFormattingTag),
-       ALIGN(plainTextFormattingTag),
-       ASIDE(plainTextFormattingTag),
-       BLOCKQUOTE(spacedFormattingTag),
+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 = " "
        
-       // Metadata
-       DESC(plainTextFormattingTag),
-       THUMB(embeddedFormattingTag),
-       
-       // Resource showing
-       IMAGE(embeddedFormattingTag),
-       MODEL(embeddedFormattingTag),
-       AUDIO(embeddedFormattingTag),
-       QUIZ(embeddedBlockFormattingTag),
+       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 -> ""
+               }
+       }
        
-       // Lists
-       UL(spacedFormattingTag),
-       OL(spacedFormattingTag),
-       LI(spacedFormattingTag),
+       override fun processText(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, text: String): PlainTextBuilderSubject {
+               return text
+       }
        
-       // Tables
-       TABLE(spacedFormattingTag),
-       TR(spacedFormattingTag),
-       TD(spacedFormattingTag),
-       TH(spacedFormattingTag),
+       override fun processLineBreak(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>): PlainTextBuilderSubject {
+               return replaceLineBreak()
+       }
        
-       // Hyperformatting
-       LINK(plainTextFormattingTag),
-       EXTLINK(plainTextFormattingTag),
-       ANCHOR(embeddedFormattingTag),
-       REDIRECT(embeddedFormattingTag),
+       override fun combine(env: LexerTagEnvironment<PlainTextBuilderContext, PlainTextBuilderSubject>, subjects: List<PlainTextBuilderSubject>): PlainTextBuilderSubject {
+               return subjects.joinToString(separator = "")
+       }
+}
+
+val PlainTextFormattingTag = LexerTags.empty<PlainTextBuilderContext, PlainTextBuilderSubject>()
+
+object PlainTextProcessor : PlainTextFormattingProcessor() {
+       private val inlineTags = setOf(
+               "b",
+               "i",
+               "u",
+               "s",
+               "color",
+               "ipa",
+               "code",
+               "h1",
+               "h2",
+               "h3",
+               "h4",
+               "h5",
+               "h6",
+               "align",
+               "aside",
+               "desc",
+               "link",
+               "extlink",
+               "lang",
+       )
        
-       // Conlangs
-       LANG(plainTextFormattingTag),
-       ALPHABET(embeddedBlockFormattingTag),
-       VOCAB(embeddedBlockFormattingTag),
-       ;
+       private val blockTags = listOf(
+               "sup",
+               "sub",
+               "quote",
+               "blockquote",
+               "ul",
+               "ol",
+               "li",
+               "table",
+               "tr",
+               "td",
+               "th",
+       )
        
-       companion object {
-               val asTags: TextParserTags<Unit> by lazy {
-                       TextParserTags(entries.associate { it.name to it.type })
+       override fun getTagBehavior(tag: String): PlainTextTagBehavior {
+               return when (tag) {
+                       in inlineTags -> PlainTextTagBehavior.PASS_THROUGH
+                       in blockTags -> PlainTextTagBehavior.PASS_THROUGH_SPACED
+                       else -> PlainTextTagBehavior.ABSORB
                }
        }
 }
 
-enum class TextParserCommentTagsPlainText(val type: TextParserTagType<Unit>) {
-       B(plainTextFormattingTag),
-       I(plainTextFormattingTag),
-       U(plainTextFormattingTag),
-       S(plainTextFormattingTag),
-       SUP(spacedFormattingTag),
-       SUB(spacedFormattingTag),
-       IPA(plainTextFormattingTag),
-       CODE(plainTextFormattingTag),
-       COLOR(plainTextFormattingTag),
-       
-       ALIGN(plainTextFormattingTag),
-       ASIDE(plainTextFormattingTag),
-       
-       UL(spacedFormattingTag),
-       OL(spacedFormattingTag),
-       LI(spacedFormattingTag),
-       
-       TABLE(spacedFormattingTag),
-       TR(spacedFormattingTag),
-       TD(spacedFormattingTag),
-       TH(spacedFormattingTag),
-       URL(spacedFormattingTag),
-       
-       LANG(plainTextFormattingTag),
-       
-       IMGBB(embeddedFormattingTag),
-       
-       REPLY(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> ">>" },
-                       { _ -> "" },
-               )
-       ),
-       
-       QUOTE(spacedFormattingTag)
+enum class CommentPlainTextFormattingTag(val type: LexerTagProcessor<PlainTextBuilderContext, PlainTextBuilderSubject>) {
+       REPLY(LexerTagProcessor { env, _, subNodes ->
+               val replyContent = env.processTree(subNodes)
+               val replyId = sanitizeId(replyContent)
+               if (replyId == null)
+                       replyContent
+               else
+                       ">>$replyId"
+       }),
        ;
        
        companion object {
-               val asTags: TextParserTags<Unit> by lazy {
-                       TextParserTags(entries.associate { it.name to it.type })
-               }
+               val asTags = LexerTags(entries.associate { it.name to it.type })
        }
 }
+
+fun ParserTree.toFactbookPlainText(): String {
+       return LexerTagEnvironment(
+               Unit,
+               PlainTextFormattingTag,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+       ).processTree(this)
+}
+
+fun ParserTree.toCommentPlainText(): String {
+       return LexerTagEnvironment(
+               Unit,
+               CommentPlainTextFormattingTag.asTags,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+               PlainTextProcessor,
+       ).processTree(this)
+}
index dad72e70e6f811f0b6326e1321dcf4b31d302531..a1dbfec1148af86f2fb26da0b196f18170c01118 100644 (file)
@@ -2,233 +2,137 @@ package info.mechyrdia.lore
 
 import info.mechyrdia.Configuration
 import io.ktor.util.*
+import kotlinx.html.*
 import java.io.File
 
-fun String.toRawLink() = substringBeforeLast('#') + "?format=raw"
+fun String.toRawLink() = substringBeforeLast('#').sanitizeLink().toInternalUrl() + "?format=raw"
 
-enum class TextParserRawPageTag(val type: TextParserTagType<Unit>) {
-       B(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span data-format=\"b\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       I(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span data-format=\"i\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       U(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span data-format=\"u\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       S(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span data-format=\"s\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       IPA(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span data-format=\"ipa\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       CODE(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span data-format=\"code\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       CODE_BLOCK(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<div data-format=\"code\"><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>" }
-               )
-       ),
-       ALIGN(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val alignments = setOf("left", "center", "right", "justify")
-                               val alignment = tagParam?.takeIf { it in alignments }
-                               val styleAttr = alignment?.let { " data-align=\"$it\"" }.orEmpty()
-                               "<div$styleAttr>"
-                       },
-                       { _ -> "</div>" },
-               )
-       ),
-       ASIDE(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val floats = setOf("left", "right")
-                               val float = tagParam?.takeIf { it in floats } ?: "right"
-                               "<div data-aside=\"$float\">"
-                       },
-                       { _ -> "</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()
-                                       .replaceFirst("<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>"
+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()
+                       }
                }
-       ),
-       TABLE(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<table>" },
-                       { _ -> "</table>" },
-               )
-       ),
-       TD(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val (width, height) = getSizeParam(tagParam)
-                               val sizeAttrs = getTableSizeAttributes(width, height)
-                               
-                               "<td$sizeAttrs>"
-                       },
-                       { _ -> "</td>" },
-               )
-       ),
-       TH(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val (width, height) = getSizeParam(tagParam)
-                               val sizeAttrs = getTableSizeAttributes(width, height)
-                               
-                               "<th$sizeAttrs>"
-                       },
-                       { _ -> "</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()}\"" }.orEmpty()
-                               
-                               "<a$attr>"
-                       },
-                       { _ -> "</a>" },
-               )
-       ),
-       REDIRECT(
-               TextParserTagType.Indirect(false) { _, content, _ ->
-                       val target = TextParserState.censorText(content)
-                       val url = if (target.startsWith('/')) "/lore$target" else "./$target"
+       }
+}
+
+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() }
+                       }
+               })
+       }),
+       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)
+               
+               if (url.endsWith(".svg")) {
+                       val svg = File(Configuration.CurrentConfiguration.assetDir, "images")
+                               .combineSafe(url)
+                               .readText()
+                               .replaceFirst("<svg", "<svg${getRawImageSizeAttributes(width, height)}")
                        
-                       "<a href=\"${url.toRawLink()}\">Click here for a manual redirect</a>"
-               }
-       ),
-       LANG(
-               TextParserTagType.Direct(
-                       false,
-                       { param, _ ->
-                               val lang = param?.let { sanitizeLang(it) } ?: "foreign"
-                               "<span style=\"lang-$lang\">"
-                       },
-                       { _ -> "</span>" }
-               )
-       ),
-       ALPHABET(
-               TextParserTagType.Indirect(true) { _, _, _ ->
-                       "<p>Unfortunately, raw view does not support interactive constructed script previews</p>"
-               }
-       ),
-       VOCAB(
-               TextParserTagType.Indirect(true) { _, _, _ ->
-                       "<p>Unfortunately, raw view does not support interactive constructed language dictionaries</p>"
+                       ({ unsafe { +svg } })
+               } else {
+                       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")),
+       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: TextParserTags<Unit> by lazy {
-                       TextParserFormattingTag.asTags + TextParserTags(entries.associate { it.name to it.type })
-               }
+               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()
 fun getRawImageSizeAttributes(width: Int?, height: Int?) = width?.let { " data-width=\"$it\"" }.orEmpty() + height?.let { " data-height=\"$it\"" }.orEmpty() + " style=\"${getRawImageSizeStyleValue(width, height)}\""
-
-val NON_LANG_CHAR = Regex("[^a-z0-9\\-]")
-fun sanitizeLang(attr: String) = attr.replace(NON_LANG_CHAR, "")
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_reply.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_reply.kt
deleted file mode 100644 (file)
index dab6cdf..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.data.Comment
-import info.mechyrdia.data.Id
-
-class CommentRepliesBuilder {
-       private val repliesTo = mutableSetOf<Id<Comment>>()
-       
-       fun addReplyTag(reply: Id<Comment>) {
-               repliesTo += reply
-       }
-       
-       fun toReplySet() = repliesTo.toSet()
-}
-
-enum class TextParserReplyCounterTag(val type: TextParserTagType<CommentRepliesBuilder>) {
-       REPLY(
-               TextParserTagType.Indirect(false) { _, content, builder ->
-                       sanitizeId(content)?.let { id ->
-                               builder.addReplyTag(Id(id))
-                       }
-                       "[reply]$content[/reply]"
-               }
-       );
-       
-       companion object {
-               val asTags: TextParserTags<CommentRepliesBuilder> by lazy {
-                       TextParserTags(entries.associate { it.name to it.type })
-               }
-       }
-}
-
-fun getReplies(commentContents: String): Set<Id<Comment>> {
-       val builder = CommentRepliesBuilder()
-       
-       TextParserState.parseText(commentContents, TextParserReplyCounterTag.asTags, builder)
-       
-       return builder.toReplySet()
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt
deleted file mode 100644 (file)
index 35177c5..0000000
+++ /dev/null
@@ -1,545 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.Configuration
-import info.mechyrdia.JsonStorageCodec
-import io.ktor.util.*
-import kotlinx.serialization.json.JsonPrimitive
-import java.io.File
-
-sealed class TextParserTagType<TContext> {
-       abstract val isBlock: Boolean
-       
-       data class Direct<TContext>(override val isBlock: Boolean, val beginFunc: (String?, TContext) -> String, val endFunc: (TContext) -> String) : TextParserTagType<TContext>() {
-               fun begin(param: String?, context: TContext) = beginFunc(param, context)
-               fun end(context: TContext) = endFunc(context)
-       }
-       
-       data class Indirect<TContext>(override val isBlock: Boolean, val processFunc: (String?, String, TContext) -> String) : TextParserTagType<TContext>() {
-               fun process(param: String?, content: String, context: TContext) = processFunc(param, content, context)
-       }
-}
-
-@JvmInline
-value class TextParserTags<TContext> private constructor(private val tags: Map<String, TextParserTagType<TContext>>) {
-       operator fun get(name: String) = tags[name.lowercase()]
-       
-       operator fun plus(other: TextParserTags<TContext>) = TextParserTags(tags + other.tags)
-       
-       companion object {
-               operator fun <TContext> invoke(tags: Map<String, TextParserTagType<TContext>>) = TextParserTags(tags.mapKeys { (name, _) -> name.lowercase() })
-               
-               fun <TContext> byIgnoringContext(tags: TextParserTags<Unit>) = TextParserTags<TContext>(tags.tags.mapValues { (_, tag) ->
-                       when (tag) {
-                               is TextParserTagType.Direct -> TextParserTagType.Direct(
-                                       tag.isBlock,
-                                       { param, _ -> tag.begin(param, Unit) },
-                                       { _ -> tag.end(Unit) }
-                               )
-                               
-                               is TextParserTagType.Indirect -> TextParserTagType.Indirect(tag.isBlock) { param, content, _ ->
-                                       tag.process(param, content, Unit)
-                               }
-                       }
-               })
-       }
-}
-
-fun <TContext> TextParserTags<Unit>.ignoreContext() = TextParserTags.byIgnoringContext<TContext>(this)
-
-enum class TextParserFormattingTag(val type: TextParserTagType<Unit>) {
-       // Basic formatting
-       B(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span style=\"font-weight:bold\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       I(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span style=\"font-style:italic\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       U(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span style=\"text-decoration:underline\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       S(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span style=\"text-decoration:line-through\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       SUP(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<sup>" },
-                       { _ -> "</sup>" },
-               )
-       ),
-       SUB(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<sub>" },
-                       { _ -> "</sub>" },
-               )
-       ),
-       COLOR(
-               TextParserTagType.Direct(
-                       false,
-                       { tagParam, _ ->
-                               val color = tagParam?.toIntOrNull(16)?.toString(16)?.padStart(6, '0')
-                               val style = color?.let { " style=\"color:#$it\"" }.orEmpty()
-                               "<span$style>"
-                       },
-                       { _ -> "</span>" },
-               )
-       ),
-       IPA(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span style=\"font-family:DejaVu Sans\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       CODE(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "<span style=\"font-family:JetBrains Mono\">" },
-                       { _ -> "</span>" },
-               )
-       ),
-       CODE_BLOCK(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<div style=\"font-family:JetBrains Mono\"><pre>" },
-                       { _ -> "</pre></div>" },
-               )
-       ),
-       H1(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       "<h1>$content</h1><script>window.checkRedirectTarget(\"\");</script>"
-               }
-       ),
-       H2(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       val anchor = headerContentToAnchor(content)
-                       "</section><section><h2><a id=\"$anchor\"></a>$content</h2><script>window.checkRedirectTarget(\"#$anchor\");</script>"
-               }
-       ),
-       H3(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       val anchor = headerContentToAnchor(content)
-                       "<h3><a id=\"$anchor\"></a>$content</h3><script>window.checkRedirectTarget(\"#$anchor\");</script>"
-               }
-       ),
-       H4(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       val anchor = headerContentToAnchor(content)
-                       "<h4><a id=\"$anchor\"></a>$content</h4><script>window.checkRedirectTarget(\"#$anchor\");</script>"
-               }
-       ),
-       H5(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       val anchor = headerContentToAnchor(content)
-                       "<h5><a id=\"$anchor\"></a>$content</h5><script>window.checkRedirectTarget(\"#$anchor\");</script>"
-               }
-       ),
-       H6(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       val anchor = headerContentToAnchor(content)
-                       "<h6><a id=\"$anchor\"></a>$content</h6><script>window.checkRedirectTarget(\"#$anchor\");</script>"
-               }
-       ),
-       ALIGN(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val alignments = setOf("left", "center", "right", "justify")
-                               val alignment = tagParam?.takeIf { it in alignments }
-                               val styleAttr = alignment?.let { " style=\"text-align:$it\"" }.orEmpty()
-                               "<div$styleAttr>"
-                       },
-                       { _ -> "</div>" },
-               )
-       ),
-       ASIDE(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val floats = setOf("left", "right")
-                               val float = tagParam?.takeIf { it in floats } ?: "right"
-                               "<div style=\"float:$float;max-width:var(--aside-width)\">"
-                       },
-                       { _ -> "</div>" },
-               )
-       ),
-       BLOCKQUOTE(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<blockquote>" },
-                       { _ -> "</blockquote>" }
-               )
-       ),
-       
-       // Metadata
-       DESC(
-               TextParserTagType.Direct(
-                       false,
-                       { _, _ -> "" },
-                       { _ -> "" },
-               )
-       ),
-       THUMB(
-               TextParserTagType.Indirect(false) { _, _, _ -> "" }
-       ),
-       
-       // Resource showing
-       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()
-                                       .replaceFirst("<svg", "<svg${getImageSizeAttributes(width, height)}")
-                       else
-                               "<script>window.appendImageThumb(\"/assets/images/$imageUrl\", \"${getImageSizeStyleValue(width, height)}\");</script>"
-               }
-       ),
-       MODEL(
-               TextParserTagType.Indirect(false) { tagParam, content, _ ->
-                       val modelUrl = sanitizeLink(content)
-                       
-                       val (width, height) = getSizeParam(tagParam)
-                       val sizeAttrs = getImageSizeAttributes(width, height)
-                       
-                       "<canvas data-model=\"$modelUrl\"$sizeAttrs></canvas>"
-               }
-       ),
-       AUDIO(
-               TextParserTagType.Indirect(false) { _, content, _ ->
-                       val audioUrl = sanitizeLink(content)
-                       
-                       "<audio src=\"/assets/sounds/$audioUrl\" controls></audio>"
-               }
-       ),
-       QUIZ(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       if (content.isBlank())
-                               ""
-                       else {
-                               val contentJson = JsonStorageCodec.parseToJsonElement(TextParserState.uncensorText(content))
-                               "<script>window.renderQuiz($contentJson);</script>"
-                       }
-               }
-       ),
-       
-       // Lists
-       UL(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<ul>" },
-                       { _ -> "</ul>" },
-               )
-       ),
-       OL(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<ol>" },
-                       { _ -> "</ol>" },
-               )
-       ),
-       LI(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<li>" },
-                       { _ -> "</li>" },
-               )
-       ),
-       
-       // Tables
-       TABLE(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<table>" },
-                       { _ -> "</table>" },
-               )
-       ),
-       TR(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<tr>" },
-                       { _ -> "</tr>" },
-               )
-       ),
-       TD(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val (width, height) = getSizeParam(tagParam)
-                               val sizeAttrs = getTableSizeAttributes(width, height)
-                               
-                               "<td$sizeAttrs>"
-                       },
-                       { _ -> "</td>" },
-               )
-       ),
-       TH(
-               TextParserTagType.Direct(
-                       true,
-                       { tagParam, _ ->
-                               val (width, height) = getSizeParam(tagParam)
-                               val sizeAttrs = getTableSizeAttributes(width, height)
-                               
-                               "<th$sizeAttrs>"
-                       },
-                       { _ -> "</th>" },
-               )
-       ),
-       
-       // Hyperformatting
-       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\"" }.orEmpty()
-                               
-                               "<a$attr>"
-                       },
-                       { _ -> "</a>" },
-               )
-       ),
-       EXTLINK(
-               TextParserTagType.Direct(
-                       false,
-                       { tagParam, _ ->
-                               val url = tagParam?.let { TextParserState.censorText(it) }
-                               val attr = url?.let { " href=\"$it\"" }.orEmpty()
-                               
-                               "<a$attr>"
-                       },
-                       { _ -> "</a>" },
-               )
-       ),
-       ANCHOR(
-               TextParserTagType.Indirect(false) { _, content, _ ->
-                       val anchor = sanitizeLink(content)
-                       "<a id=\"$anchor\" name=\"$anchor\"></a>"
-               }
-       ),
-       REDIRECT(
-               TextParserTagType.Indirect(false) { _, content, _ ->
-                       val target = TextParserState.censorText(content)
-                       val url = if (target.startsWith('/')) "/lore$target" else "./$target"
-                       val string = JsonPrimitive(url).toString()
-                       
-                       "<script>window.factbookRedirect($string);</script>"
-               }
-       ),
-       
-       // Conlangs
-       LANG(
-               TextParserTagType.Indirect(false) { tagParam, content, _ ->
-                       if (tagParam?.equals("tylan", ignoreCase = true) == true) {
-                               val uncensored = TextParserState.uncensorText(content)
-                               val tylan = TylanAlphabetFont.tylanToFontAlphabet(uncensored)
-                               val recensored = TextParserState.censorText(tylan)
-                               "<span class=\"lang-tylan\">$recensored</span>"
-                       } else if (tagParam?.equals("thedish", ignoreCase = true) == true)
-                               "<span class=\"lang-thedish\">$content</span>"
-                       else if (tagParam?.equals("kishari", ignoreCase = true) == true)
-                               "<span class=\"lang-kishari\">$content</span>"
-                       else if (tagParam?.equals("pokhval", ignoreCase = true) == true || tagParam?.equals("pokhwal", ignoreCase = true) == true) {
-                               val uncensored = TextParserState.uncensorText(content)
-                               val pokhwal = PokhwalishAlphabetFont.pokhwalToFontAlphabet(uncensored)
-                               val recensored = TextParserState.censorText(pokhwal)
-                               "<span class=\"lang-pokhwal\">$recensored</span>"
-                       } else content
-               }
-       ),
-       ALPHABET(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       if (content.equals("mechyrdian", ignoreCase = true)) {
-                               """
-                                       |<div class="mechyrdia-sans-box">
-                                       |<p>Input Text:</p>
-                                       |<textarea class="input-box" spellcheck="false"></textarea>
-                                       |<p>Font options:</p>
-                                       |<ul>
-                                       |<li><label><input type="checkbox" class="bold-option"/>&nbsp;Bold</label></li>
-                                       |<li><label><input type="checkbox" class="ital-option"/>&nbsp;Italic</label></li>
-                                       |<li><label>Align:&nbsp;<select class="align-opts"><option value="left" selected>Left</option><option value="center">Center</option><option value="right">Right</option></select></label></li>
-                                       |</ul>
-                                       |<p>Rendered in Mechyrdia Sans:</p>
-                                       |<img class="output-img" style="display:block;max-width:100%"/>
-                                       |</div>
-                               """.trimMargin()
-                       } else if (content.equals("tylan", ignoreCase = true)) {
-                               """
-                                       |<div class="tylan-alphabet-box">
-                                       |<p>Latin Alphabet:</p>
-                                       |<textarea class="input-box" spellcheck="false"></textarea>
-                                       |<p>Tylan Alphabet:</p>
-                                       |<textarea class="output-box lang-tylan" readonly></textarea>
-                                       |</div>
-                               """.trimMargin()
-                       } else if (content.equals("thedish", ignoreCase = true)) {
-                               """
-                                       |<div class="thedish-alphabet-box">
-                                       |<p>Latin Alphabet:</p>
-                                       |<textarea class="input-box" spellcheck="false"></textarea>
-                                       |<p>Thedish Alphabet:</p>
-                                       |<textarea class="output-box lang-thedish" readonly></textarea>
-                                       |</div>
-                               """.trimMargin()
-                       } else if (content.equals("kishari", ignoreCase = true)) {
-                               """
-                                       |<div class="kishari-alphabet-box">
-                                       |<p>Latin Alphabet:</p>
-                                       |<textarea class="input-box" spellcheck="false"></textarea>
-                                       |<p>Kishari Alphabet:</p>
-                                       |<textarea class="output-box lang-kishari" readonly></textarea>
-                                       |</div>
-                               """.trimMargin()
-                       } else if (content.equals("pokhval", ignoreCase = true) || content.equals("pokhwal", ignoreCase = true)) {
-                               """
-                                       |<div class="pokhwal-alphabet-box">
-                                       |<p>Latin Alphabet:</p>
-                                       |<textarea class="input-box" spellcheck="false"></textarea>
-                                       |<p>Pokhwalish Alphabet:</p>
-                                       |<textarea class="output-box lang-pokhwal" readonly></textarea>
-                                       |</div>
-                               """.trimMargin()
-                       } else ""
-               }
-       ),
-       VOCAB(
-               TextParserTagType.Indirect(true) { _, content, _ ->
-                       if (content.isBlank())
-                               ""
-                       else {
-                               val contentJson = JsonStorageCodec.parseToJsonElement(TextParserState.uncensorText(content))
-                               "<script>window.renderVocab($contentJson);</script>"
-                       }
-               }
-       ),
-       ;
-       
-       companion object {
-               val asTags: TextParserTags<Unit> by lazy {
-                       TextParserTags(entries.associate { it.name to it.type })
-               }
-       }
-}
-
-enum class TextParserCommentTags(val type: TextParserTagType<Unit>) {
-       B(TextParserFormattingTag.B.type),
-       I(TextParserFormattingTag.I.type),
-       U(TextParserFormattingTag.U.type),
-       S(TextParserFormattingTag.S.type),
-       SUP(TextParserFormattingTag.SUP.type),
-       SUB(TextParserFormattingTag.SUB.type),
-       IPA(TextParserFormattingTag.IPA.type),
-       CODE(TextParserFormattingTag.CODE.type),
-       COLOR(TextParserFormattingTag.COLOR.type),
-       
-       ALIGN(TextParserFormattingTag.ALIGN.type),
-       ASIDE(TextParserFormattingTag.ASIDE.type),
-       
-       UL(TextParserFormattingTag.UL.type),
-       OL(TextParserFormattingTag.OL.type),
-       LI(TextParserFormattingTag.LI.type),
-       
-       TABLE(TextParserFormattingTag.TABLE.type),
-       TR(TextParserFormattingTag.TR.type),
-       TD(TextParserFormattingTag.TD.type),
-       TH(TextParserFormattingTag.TH.type),
-       URL(
-               TextParserTagType.Direct(
-                       false,
-                       { tagParam, _ ->
-                               val url = tagParam?.let { TextParserState.censorText(it) }
-                               val attr = url?.let { " href=\"$it\" rel=\"ugc nofollow\"" }.orEmpty()
-                               
-                               "<a$attr>"
-                       },
-                       { "</a>" },
-               )
-       ),
-       
-       LANG(TextParserFormattingTag.LANG.type),
-       
-       IMGBB(
-               TextParserTagType.Indirect(false) { tagParam, content, _ ->
-                       val imageUrl = sanitizeExtLink(content)
-                       val (width, height) = getSizeParam(tagParam)
-                       
-                       "<img src=\"https://i.ibb.co/$imageUrl\"${getImageSizeAttributes(width, height)}/>"
-               }
-       ),
-       
-       REPLY(
-               TextParserTagType.Indirect(false) { _, content, _ ->
-                       sanitizeId(content)?.let { id ->
-                               "<a href=\"/comment/view/$id\" rel=\"ugc\">&gt;&gt;$id</a>"
-                       } ?: "[reply]$content[/reply]"
-               }
-       ),
-       
-       QUOTE(
-               TextParserTagType.Direct(
-                       true,
-                       { _, _ -> "<blockquote>" },
-                       { _ -> "</blockquote>" }
-               )
-       )
-       ;
-       
-       companion object {
-               val asTags: TextParserTags<Unit> by lazy {
-                       TextParserTags(entries.associate { it.name to it.type })
-               }
-       }
-}
-
-val NON_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._]")
-val DOT_CHARS = Regex("\\.+")
-fun sanitizeLink(html: String) = html.replace(NON_LINK_CHAR, "").replace(DOT_CHARS, ".")
-
-val NON_EXT_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._/]")
-fun sanitizeExtLink(html: String) = html.replace(NON_EXT_LINK_CHAR, "").replace(DOT_CHARS, ".")
-
-val ID_REGEX = Regex("[A-IL-TVX-Z0-9]{24}")
-fun sanitizeId(html: String) = ID_REGEX.matchEntire(html)?.value
-
-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 getTableSizeAttributes(width: Int?, height: Int?) = width?.let { " colspan=\"$it\"" }.orEmpty() + height?.let { " rowspan=\"$it\"" }.orEmpty()
-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)}\""
-
-val NON_ANCHOR_CHAR = Regex("[^a-zA-Z\\d\\-]")
-val INSIDE_TAG_TEXT = Regex("\\[.*?]")
-
-fun headerContentToLabel(content: String) = TextParserState.uncensorText(content.replace(INSIDE_TAG_TEXT, ""))
-fun headerContentToAnchor(content: String) = headerContentToLabel(content).replace(NON_ANCHOR_CHAR, "-")
-
-fun imagePathToOpenGraphValue(path: String) = "https://mechyrdia.info/assets/images/${sanitizeLink(path)}"
-fun descriptionContentToPlainText(content: String) = TextParserState.uncensorText(content.replace(INSIDE_TAG_TEXT, ""))
-
-fun commentToPlainText(contentRaw: String) = TextParserState.uncensorText(TextParserState.parseText(contentRaw, TextParserCommentTagsPlainText.asTags, Unit).replace("<p>", " ").replace("</p>", " ").replace("<br/>", " ").replace(Regex("\\s+"), " "))
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_toc.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_toc.kt
deleted file mode 100644 (file)
index fda0e86..0000000
+++ /dev/null
@@ -1,116 +0,0 @@
-package info.mechyrdia.lore
-
-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.plusAssign(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"
-       }
-}
-
-enum class TextParserToCBuilderTag(val type: TextParserTagType<TableOfContentsBuilder>) {
-       H1(
-               TextParserTagType.Indirect(true) { _, content, builder ->
-                       builder.addHeader(headerContentToLabel(content), 0, headerContentToAnchor(content))
-                       content
-               }
-       ),
-       H2(
-               TextParserTagType.Indirect(true) { _, content, builder ->
-                       builder.addHeader(headerContentToLabel(content), 1, headerContentToAnchor(content))
-                       content
-               }
-       ),
-       H3(
-               TextParserTagType.Indirect(true) { _, content, builder ->
-                       builder.addHeader(headerContentToLabel(content), 2, headerContentToAnchor(content))
-                       content
-               }
-       ),
-       H4(
-               TextParserTagType.Indirect(true) { _, content, builder ->
-                       builder.addHeader(headerContentToLabel(content), 3, headerContentToAnchor(content))
-                       content
-               }
-       ),
-       H5(
-               TextParserTagType.Indirect(true) { _, content, builder ->
-                       builder.addHeader(headerContentToLabel(content), 4, headerContentToAnchor(content))
-                       content
-               }
-       ),
-       H6(
-               TextParserTagType.Indirect(true) { _, content, builder ->
-                       builder.addHeader(headerContentToLabel(content), 5, headerContentToAnchor(content))
-                       content
-               }
-       ),
-       DESC(
-               TextParserTagType.Indirect(false) { _, content, builder ->
-                       builder.addDescription(descriptionContentToPlainText(content))
-                       content
-               }
-       ),
-       IMAGE(
-               TextParserTagType.Indirect(false) { _, content, builder ->
-                       builder.addImage(imagePathToOpenGraphValue(content))
-                       ""
-               }
-       ),
-       THUMB(
-               TextParserTagType.Indirect(false) { _, content, builder ->
-                       builder.addImage(imagePathToOpenGraphValue(content), true)
-                       ""
-               }
-       );
-       
-       companion object {
-               val asTags: TextParserTags<TableOfContentsBuilder> by lazy {
-                       TextParserFormattingTagPlainText.asTags.ignoreContext<TableOfContentsBuilder>() + TextParserTags(entries.associate { it.name to it.type })
-               }
-       }
-}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tree.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tree.kt
new file mode 100644 (file)
index 0000000..f868933
--- /dev/null
@@ -0,0 +1,198 @@
+package info.mechyrdia.lore
+
+fun <TSubject> String.parseAs(converter: ParserTree.() -> TSubject) = ParserState.parseText(this).converter()
+
+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()
+}
+
+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
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()
+}
index c598802fc7464dbc8becddfa5e572b136a1287c8..746744c4f608c7411947fc9da7b83c517e4df754 100644 (file)
@@ -32,7 +32,7 @@ val preloadImages = listOf(
        "icon.png",
 )
 
-fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sidebar: Sidebar? = null, ogData: OpenGraphData? = null, content: SECTIONS.() -> Unit): HTML.() -> Unit {
+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 }
                
@@ -113,9 +113,7 @@ fun ApplicationCall.page(pageTitle: String, navBar: List<NavItem>? = null, sideb
                                        }
                                }
                                
-                               with(sectioned()) {
-                                       content()
-                               }
+                               content()
                                
                                navBar?.let { nb ->
                                        nav(classes = "mobile") {
index 2afad1389d6de376bf9e54f7275157a091cfc1fe..0be3fd5a9d0b7e79a5b5b16f9dffb08f6ad67629 100644 (file)
@@ -101,14 +101,14 @@ fun ApplicationCall.loreRawArticlePage(pagePathParts: List<String>): HTML.() ->
                if (pageFile.isFile) {
                        val pageTemplate = pageFile.readText()
                        val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
-                       val pageHtml = TextParserState.parseText(pageMarkup, TextParserRawPageTag.asTags, Unit)
+                       val pageHtml = pageMarkup.parseAs(ParserTree::toRawHtml)
                        
                        val pageToC = TableOfContentsBuilder()
-                       TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
+                       pageMarkup.parseAs(pageToC, ParserTree::buildToC)
                        
                        return rawPage(pageToC.toPageTitle(), pageToC.toOpenGraph()) {
                                breadCrumbs(parentPaths)
-                               unsafe { raw(pageHtml) }
+                               +pageHtml
                        }
                }
        }
@@ -176,10 +176,10 @@ suspend fun ApplicationCall.loreArticlePage(pagePathParts: List<String>, format:
                if (pageFile.isFile) {
                        val pageTemplate = pageFile.readText()
                        val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
-                       val pageHtml = TextParserState.parseText(pageMarkup, TextParserFormattingTag.asTags, Unit)
+                       val pageHtml = pageMarkup.parseAs(ParserTree::toFactbookHtml)
                        
                        val pageToC = TableOfContentsBuilder()
-                       TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
+                       pageMarkup.parseAs(pageToC, ParserTree::buildToC)
                        
                        val pageNav = pageToC.toNavBar() + NavLink("#comments", "Comments", aClasses = "left")
                        
@@ -187,10 +187,7 @@ suspend fun ApplicationCall.loreArticlePage(pagePathParts: List<String>, format:
                        val sidebar = PageNavSidebar(pageNav)
                        
                        return page(pageToC.toPageTitle(), navbar, sidebar, pageToC.toOpenGraph()) {
-                               section {
-                                       a { id = "page-top" }
-                                       unsafe { raw(pageHtml) }
-                               }
+                               +pageHtml
                                
                                finalSection(pagePathParts, canCommentAs, comments, totalsData)
                        }
@@ -222,7 +219,7 @@ suspend fun ApplicationCall.loreArticlePage(pagePathParts: List<String>, format:
 }
 
 context(ApplicationCall)
-private fun SECTIONS.finalSection(pagePathParts: List<String>, canCommentAs: NationData?, comments: List<CommentRenderData>, totalsData: PageVisitTotals) {
+private fun SectioningOrFlowContent.finalSection(pagePathParts: List<String>, canCommentAs: NationData?, comments: List<CommentRenderData>, totalsData: PageVisitTotals) {
        section {
                h2 {
                        a { id = "comments" }
index f224f4f600fdbfd123b9df69732e8e7d3024ef11..affa3b26056354489e9b5d705edb8a80426f75e3 100644 (file)
@@ -56,7 +56,7 @@ fun Appendable.generateRecentPageEdits() {
                        val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
                        
                        val pageToC = TableOfContentsBuilder()
-                       TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
+                       pageMarkup.parseAs(pageToC, ParserTree::buildToC)
                        val pageOg = pageToC.toOpenGraph()
                        
                        val imageEnclosure = pageOg?.image?.let { url ->
@@ -129,7 +129,7 @@ suspend fun ApplicationCall.recentCommentsRssFeedGenerator(): Appendable.() -> U
                items = comments.map { comment ->
                        RssItem(
                                title = "Comment by ${comment.submittedBy.name} on https://mechyrdia.info/lore/${comment.submittedIn}",
-                               description = commentToPlainText(comment.contentsRaw),
+                               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}",
index 10d0912fbdcb305aeac93b876bdc9f6170970456..685cb88f8c4bd77b795b4129af24aee519e1f6d0 100644 (file)
@@ -316,7 +316,7 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
                                with(utils) { filterCall() }
                                
                                call.respondText(
-                                       text = TextParserState.parseText(payload.lines.joinToString(separator = "\n"), TextParserCommentTags.asTags, Unit),
+                                       text = payload.lines.joinToString(separator = "\n").parseAs(ParserTree::toCommentHtml).toFragment(),
                                        contentType = ContentType.Text.Html
                                )
                        }