From: Lanius Trolling Date: Fri, 5 Apr 2024 16:06:42 +0000 (-0400) Subject: Rewrite BBCode parser X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=6bdbc6e853a77bb7d1f76e4484761c53db2d32e6;p=factbooks Rewrite BBCode parser --- diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt b/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt index 6703673..9137260 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt @@ -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>, ) { @@ -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 p { style = "font-size:0.8em" diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt b/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt index 1544645..257cab9 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/views_comment.kt @@ -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 index 2b6cf90..0000000 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser.kt +++ /dev/null @@ -1,252 +0,0 @@ -package info.mechyrdia.lore - -data class TextParserScope( - val write: StringBuilder, - val tags: TextParserTags, - val ctx: TContext, -) - -class TextParserInternalState { - var suppressEndParagraph: Boolean = false -} - -sealed class TextParserState( - val scope: TextParserScope, - val insideTags: List, - protected val internalState: TextParserInternalState, -) { - abstract fun processCharacter(char: Char): TextParserState - 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) - "

" - else - "

" - } else - "

" - - appendTextRaw(newline) - } - - protected fun lastParagraph() { - if (!internalState.suppressEndParagraph) - appendTextRaw("

") - } - - protected fun cancelEndParagraph() { - internalState.suppressEndParagraph = true - } - - protected fun cancelStartParagraph() { - if (scope.write.endsWith("

")) - scope.write.deleteRange(scope.write.length - "

".length, scope.write.length) - } - - class Initial(scope: TextParserScope) : TextParserState(scope, listOf(), TextParserInternalState()) { - override fun processCharacter(char: Char): TextParserState { - return if (char == '[') - OpenTag(scope, "", insideTags, internalState) - else { - appendTextRaw("

") - PlainText(scope, "$char", insideTags, internalState) - } - } - - override fun processEndOfText() { - // Do nothing - } - } - - class PlainText(scope: TextParserScope, private val text: String, insideTags: List, internalState: TextParserInternalState) : TextParserState(scope, insideTags, internalState) { - override fun processCharacter(char: Char): TextParserState { - 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(scope: TextParserScope, private val text: String, insideTags: List, internalState: TextParserInternalState) : TextParserState(scope, insideTags, internalState) { - override fun processCharacter(char: Char): TextParserState { - 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("

") - NoFormatText(scope, "", insideTags, internalState) - } else - NoFormatText(scope, newText, insideTags, internalState) - } - - override fun processEndOfText() { - appendText(text.removeSuffix("\n")) - if (text.isNotBlank()) - lastParagraph() - } - } - - class InsideIndirectTag(scope: TextParserScope, private val tagName: String, private val tagType: TextParserTagType.Indirect, private val param: String?, private val text: String, insideTags: List, internalState: TextParserInternalState) : TextParserState(scope, insideTags, internalState) { - override fun processCharacter(char: Char): TextParserState { - 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(scope: TextParserScope, private val tag: String, insideTags: List, internalState: TextParserInternalState) : TextParserState(scope, insideTags, internalState) { - override fun processCharacter(char: Char): TextParserState { - 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 -> { - appendTextRaw(tagType.begin(null, scope.ctx)) - PlainText(scope, "", insideTags + tag, internalState) - } - - is TextParserTagType.Indirect -> 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(scope: TextParserScope, private val tag: String, private val param: String, insideTags: List, internalState: TextParserInternalState) : TextParserState(scope, insideTags, internalState) { - override fun processCharacter(char: Char): TextParserState { - return if (char == ']') { - val tagType = scope.tags[tag] - if (tagType?.isBlock == true) - cancelStartParagraph() - - when (tagType) { - is TextParserTagType.Direct -> { - appendTextRaw(tagType.begin(param, scope.ctx)) - PlainText(scope, "", insideTags + tag, internalState) - } - - is TextParserTagType.Indirect -> 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(scope: TextParserScope, private val tag: String, insideTags: List, internalState: TextParserInternalState) : TextParserState(scope, insideTags, internalState) { - override fun processCharacter(char: Char): TextParserState { - return if (char == ']') { - val tagType = scope.tags[tag] - val nextState = if (tagType is TextParserTagType.Direct && 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("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - - fun uncensorText(censored: String) = censored - .replace(""", "\"") - .replace("<", "<") - .replace(">", ">") - .replace("&", "&") - - fun parseText(text: String, tags: TextParserTags, context: TContext): String { - val builder = StringBuilder() - val fixedText = text.replace("\r\n", "\n").replace('\r', '\n') - fixedText - .fold>(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 index 0000000..f5b90d4 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_builder.kt @@ -0,0 +1,169 @@ +package info.mechyrdia.lore + +import info.mechyrdia.data.Comment +import info.mechyrdia.data.Id + +abstract class BuilderLexerProcessor : LexerTagFallback, LexerTextProcessor, LexerLineBreakProcessor, LexerCombiner { + override fun processInvalidTag(env: LexerTagEnvironment, tag: String, param: String?, subNodes: ParserTree) { + // no-op + } + + override fun processText(env: LexerTagEnvironment, text: String) { + // no-op + } + + override fun processLineBreak(env: LexerTagEnvironment) { + // no-op + } + + override fun combine(env: LexerTagEnvironment, subjects: List) { + // no-op + } +} + +typealias BuilderTag = LexerTagProcessor + +object ToCBuilderProcessor : BuilderLexerProcessor() + +class TableOfContentsBuilder { + private var title: String? = null + private val levels = mutableListOf() + private val links = mutableListOf() + + 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 = 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 { + override fun processTag(env: LexerTagEnvironment, 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 { + override fun processTag(env: LexerTagEnvironment, 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) { + 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() + +class CommentRepliesBuilder { + private val repliesTo = mutableSetOf>() + + fun addReplyTag(reply: Id) { + 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) { + 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> { + 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 index 0000000..ecbb623 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_html.kt @@ -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 (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 { + 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 > String.unaryPlus() = onTagContent(this) + +context(C) +operator fun > Entities.unaryPlus() = onTagContentEntity(this) + +context(C) +fun > 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 = splitBefore { + it is ParserTreeNode.Tag && it.tag.lowercase() == "h2" +} + +fun ParserTreeNode.isParagraph(inlineTags: Set): 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): Boolean = any { it.isParagraph(inlineTags) } + +fun ParserTree.splitParagraphs(): List = splitOn { it == ParserTreeNode.LineBreak } + +fun ParserTree.toHtmlParagraph(env: LexerTagEnvironment) = 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): List = + splitParagraphs().mapNotNull { paragraph -> + paragraph.toHtmlParagraph(env) + } + +object HtmlLexerProcessor : LexerTagFallback, LexerTextProcessor, LexerLineBreakProcessor, LexerCombiner { + val inlineTags = setOf( + "b", + "i", + "u", + "s", + "sup", + "sub", + "color", + "ipa", + "code", + "desc", + "link", + "extlink", + "lang", + "url", + "reply", + ) + + override fun processInvalidTag(env: LexerTagEnvironment, tag: String, param: String?, subNodes: ParserTree): HtmlBuilderSubject { + return { + +if (param == null) "[$tag]" else "[$tag=$param]" + env.processTree(subNodes)() + +"[/$tag]" + } + } + + override fun processText(env: LexerTagEnvironment, text: String): HtmlBuilderSubject { + return { +text } + } + + override fun processLineBreak(env: LexerTagEnvironment): HtmlBuilderSubject { + return { + br() + br() + } + } + + override fun processAndCombine(env: LexerTagEnvironment, nodes: ParserTree): HtmlBuilderSubject { + return combinePage(env, nodes) + } + + fun combinePage(env: LexerTagEnvironment, 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, 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, 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, nodes: ParserTree): HtmlBuilderSubject { + return combine(env, nodes.map(env::processNode)) + } + + fun combineLayout(env: LexerTagEnvironment, nodes: ParserTree): HtmlBuilderSubject { + return combine(env, nodes.filter { it !is ParserTreeNode.Text || it.text.isNotBlank() }.map(env::processNode)) + } + + override fun combine(env: LexerTagEnvironment, subjects: List): HtmlBuilderSubject { + return { for (subject in subjects) subject() } + } +} + +fun interface HtmlLexerTag : LexerTagProcessor + +class HtmlMetadataLexerTag(val absorb: Boolean) : HtmlLexerTag { + override fun processTag(env: LexerTagEnvironment, 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, param: String?, subNodes: ParserTree): HtmlBuilderSubject { + return processTag(env, param, subNodes.treeToText()) + } + + fun processTag(env: LexerTagEnvironment, param: String?, innerText: String): HtmlBuilderSubject +} + +typealias TagCreator = TagConsumer<*>.(block: Tag.() -> Unit) -> Unit + +fun (TagConsumer<*>.(T1?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator { + return { + this@toTagCreator(null, it) + } +} + +fun (TagConsumer<*>.(T1?, T2?, block: Tag.() -> Unit) -> Any?).toTagCreator(): TagCreator { + return { + this@toTagCreator(null, null, it) + } +} + +fun (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, 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 = { _ -> emptyMap() }, + val tagMode: HtmlTagMode = HtmlTagMode.BLOCK, + val tagCreator: TagCreator +) : HtmlLexerTag { + constructor(attributes: Map, tagMode: HtmlTagMode, tagCreator: TagCreator) : this({ attributes }, tagMode, tagCreator) + + override fun processTag(env: LexerTagEnvironment, 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, 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 = 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 = 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 = 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 = 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 { + 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 = param + ?.sanitizeLink() + ?.toInternalUrl() + ?.let { mapOf("href" to it) } + .orEmpty() + +fun processExternalLink(param: String?): Map = param + ?.sanitizeExtLink() + ?.toExternalUrl() + ?.let { mapOf("href" to it) } + .orEmpty() + +fun processCommentLink(param: String?): Map = processExternalLink(param) + mapOf("rel" to "ugc nofollow") + +fun processCommentImage(url: String, domain: String) = "https://$domain/${url.sanitizeExtImgLink()}" + +fun processAnchor(param: String?): Map = 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(" + 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 index 0000000..61cb796 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_lexer.kt @@ -0,0 +1,60 @@ +package info.mechyrdia.lore + +class LexerTagEnvironment( + val context: TContext, + private val processTags: LexerTags, + private val processText: LexerTextProcessor, + private val processBreak: LexerLineBreakProcessor, + private val processInvalidTag: LexerTagFallback, + private val combiner: LexerCombiner +) { + 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 private constructor(private val tags: Map>) { + operator fun get(name: String) = tags[name.lowercase()] + + operator fun plus(other: LexerTags) = LexerTags(tags + other.tags) + + companion object { + fun empty() = LexerTags(emptyMap()) + + operator fun invoke(tags: Map>) = LexerTags(tags.mapKeys { (name, _) -> name.lowercase() }) + } +} + +fun interface LexerTagProcessor { + fun processTag(env: LexerTagEnvironment, param: String?, subNodes: ParserTree): TSubject +} + +fun interface LexerTagFallback { + fun processInvalidTag(env: LexerTagEnvironment, tag: String, param: String?, subNodes: ParserTree): TSubject +} + +fun interface LexerTextProcessor { + fun processText(env: LexerTagEnvironment, text: String): TSubject +} + +fun interface LexerLineBreakProcessor { + fun processLineBreak(env: LexerTagEnvironment): TSubject +} + +fun interface LexerCombiner { + fun processAndCombine(env: LexerTagEnvironment, nodes: ParserTree): TSubject { + return combine(env, nodes.map(env::processNode)) + } + + fun combine(env: LexerTagEnvironment, subjects: List): TSubject +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt index 91d60bc..f2376d2 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_plain.kt @@ -1,123 +1,121 @@ package info.mechyrdia.lore -private val plainTextFormattingTag = TextParserTagType.Direct( - false, - { _, _ -> "" }, - { "" }, -) +typealias PlainTextBuilderContext = Unit +typealias PlainTextBuilderSubject = String -private val spacedFormattingTag = TextParserTagType.Direct( - true, - { _, _ -> " " }, - { " " }, -) - -private val embeddedFormattingTag = TextParserTagType.Indirect(false) { _, _, _ -> "" } -private val embeddedBlockFormattingTag = TextParserTagType.Indirect(true) { _, _, _ -> "" } +enum class PlainTextTagBehavior { + PASS_THROUGH, + PASS_THROUGH_SPACED, + ABSORB +} -enum class TextParserFormattingTagPlainText(val type: TextParserTagType) { - // 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, LexerTextProcessor, LexerLineBreakProcessor, LexerCombiner { + 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, 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, text: String): PlainTextBuilderSubject { + return text + } - // Tables - TABLE(spacedFormattingTag), - TR(spacedFormattingTag), - TD(spacedFormattingTag), - TH(spacedFormattingTag), + override fun processLineBreak(env: LexerTagEnvironment): PlainTextBuilderSubject { + return replaceLineBreak() + } - // Hyperformatting - LINK(plainTextFormattingTag), - EXTLINK(plainTextFormattingTag), - ANCHOR(embeddedFormattingTag), - REDIRECT(embeddedFormattingTag), + override fun combine(env: LexerTagEnvironment, subjects: List): PlainTextBuilderSubject { + return subjects.joinToString(separator = "") + } +} + +val PlainTextFormattingTag = LexerTags.empty() + +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 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) { - 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) { + REPLY(LexerTagProcessor { env, _, subNodes -> + val replyContent = env.processTree(subNodes) + val replyId = sanitizeId(replyContent) + if (replyId == null) + replyContent + else + ">>$replyId" + }), ; companion object { - val asTags: TextParserTags 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) +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt index dad72e7..a1dbfec 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_raw.kt @@ -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) { - B( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - I( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - U( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - S( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - IPA( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - CODE( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - CODE_BLOCK( - TextParserTagType.Direct( - false, - { _, _ -> "

" },
-			{ _ -> "
" }, - ) - ), - H1( - TextParserTagType.Direct( - true, - { _, _ -> "

" }, - { _ -> "

" } - ) - ), - H2( - TextParserTagType.Direct( - true, - { _, _ -> "

" }, - { _ -> "

" } - ) - ), - H3( - TextParserTagType.Direct( - true, - { _, _ -> "

" }, - { _ -> "

" } - ) - ), - H4( - TextParserTagType.Direct( - true, - { _, _ -> "

" }, - { _ -> "

" } - ) - ), - H5( - TextParserTagType.Direct( - true, - { _, _ -> "
" }, - { _ -> "
" } - ) - ), - H6( - TextParserTagType.Direct( - true, - { _, _ -> "
" }, - { _ -> "
" } - ) - ), - 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() - "" - }, - { _ -> "" }, - ) - ), - ASIDE( - TextParserTagType.Direct( - true, - { tagParam, _ -> - val floats = setOf("left", "right") - val float = tagParam?.takeIf { it in floats } ?: "right" - "
" - }, - { _ -> "
" }, - ) - ), - 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("" - } - ), - MODEL( - TextParserTagType.Indirect(false) { _, _, _ -> - "Unfortunately, raw view does not support interactive 3D model views" - } - ), - QUIZ( - TextParserTagType.Indirect(true) { _, _, _ -> - "

Unfortunately, raw view does not support interactive quizzes

" +fun processRawInternalLink(param: String?): Map = param + ?.toRawLink() + ?.let { mapOf("href" to it) } + .orEmpty() + +fun processRawLanguage(param: String?): Map = mapOf("data-lang" to (param ?: "foreign")) + +private class HtmlDataFormatTag(val dataFormat: String) : HtmlLexerTag { + override fun processTag(env: LexerTagEnvironment, param: String?, subNodes: ParserTree): HtmlBuilderSubject { + val content = HtmlLexerProcessor.combineInline(env, subNodes) + + return { + span { + attributes["data-format"] = dataFormat + content() + } } - ), - TABLE( - TextParserTagType.Direct( - true, - { _, _ -> "" }, - { _ -> "
" }, - ) - ), - TD( - TextParserTagType.Direct( - true, - { tagParam, _ -> - val (width, height) = getSizeParam(tagParam) - val sizeAttrs = getTableSizeAttributes(width, height) - - "" - }, - { _ -> "" }, - ) - ), - TH( - TextParserTagType.Direct( - true, - { tagParam, _ -> - val (width, height) = getSizeParam(tagParam) - val sizeAttrs = getTableSizeAttributes(width, height) - - "" - }, - { _ -> "" }, - ) - ), - 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() - - "" - }, - { _ -> "" }, - ) - ), - 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, 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("Click here for a manual redirect" - } - ), - LANG( - TextParserTagType.Direct( - false, - { param, _ -> - val lang = param?.let { sanitizeLang(it) } ?: "foreign" - "" - }, - { _ -> "" } - ) - ), - ALPHABET( - TextParserTagType.Indirect(true) { _, _, _ -> - "

Unfortunately, raw view does not support interactive constructed script previews

" - } - ), - VOCAB( - TextParserTagType.Indirect(true) { _, _, _ -> - "

Unfortunately, raw view does not support interactive constructed language dictionaries

" + ({ 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 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 index dab6cdf..0000000 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_reply.kt +++ /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>() - - fun addReplyTag(reply: Id) { - repliesTo += reply - } - - fun toReplySet() = repliesTo.toSet() -} - -enum class TextParserReplyCounterTag(val type: TextParserTagType) { - REPLY( - TextParserTagType.Indirect(false) { _, content, builder -> - sanitizeId(content)?.let { id -> - builder.addReplyTag(Id(id)) - } - "[reply]$content[/reply]" - } - ); - - companion object { - val asTags: TextParserTags by lazy { - TextParserTags(entries.associate { it.name to it.type }) - } - } -} - -fun getReplies(commentContents: String): Set> { - 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 index 35177c5..0000000 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tags.kt +++ /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 { - abstract val isBlock: Boolean - - data class Direct(override val isBlock: Boolean, val beginFunc: (String?, TContext) -> String, val endFunc: (TContext) -> String) : TextParserTagType() { - fun begin(param: String?, context: TContext) = beginFunc(param, context) - fun end(context: TContext) = endFunc(context) - } - - data class Indirect(override val isBlock: Boolean, val processFunc: (String?, String, TContext) -> String) : TextParserTagType() { - fun process(param: String?, content: String, context: TContext) = processFunc(param, content, context) - } -} - -@JvmInline -value class TextParserTags private constructor(private val tags: Map>) { - operator fun get(name: String) = tags[name.lowercase()] - - operator fun plus(other: TextParserTags) = TextParserTags(tags + other.tags) - - companion object { - operator fun invoke(tags: Map>) = TextParserTags(tags.mapKeys { (name, _) -> name.lowercase() }) - - fun byIgnoringContext(tags: TextParserTags) = TextParserTags(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 TextParserTags.ignoreContext() = TextParserTags.byIgnoringContext(this) - -enum class TextParserFormattingTag(val type: TextParserTagType) { - // Basic formatting - B( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - I( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - U( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - S( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - SUP( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - SUB( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - COLOR( - TextParserTagType.Direct( - false, - { tagParam, _ -> - val color = tagParam?.toIntOrNull(16)?.toString(16)?.padStart(6, '0') - val style = color?.let { " style=\"color:#$it\"" }.orEmpty() - "" - }, - { _ -> "" }, - ) - ), - IPA( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - CODE( - TextParserTagType.Direct( - false, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - CODE_BLOCK( - TextParserTagType.Direct( - true, - { _, _ -> "
" },
-			{ _ -> "
" }, - ) - ), - H1( - TextParserTagType.Indirect(true) { _, content, _ -> - "

$content

" - } - ), - H2( - TextParserTagType.Indirect(true) { _, content, _ -> - val anchor = headerContentToAnchor(content) - "

$content

" - } - ), - H3( - TextParserTagType.Indirect(true) { _, content, _ -> - val anchor = headerContentToAnchor(content) - "

$content

" - } - ), - H4( - TextParserTagType.Indirect(true) { _, content, _ -> - val anchor = headerContentToAnchor(content) - "

$content

" - } - ), - H5( - TextParserTagType.Indirect(true) { _, content, _ -> - val anchor = headerContentToAnchor(content) - "
$content
" - } - ), - H6( - TextParserTagType.Indirect(true) { _, content, _ -> - val anchor = headerContentToAnchor(content) - "
$content
" - } - ), - 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() - "" - }, - { _ -> "" }, - ) - ), - ASIDE( - TextParserTagType.Direct( - true, - { tagParam, _ -> - val floats = setOf("left", "right") - val float = tagParam?.takeIf { it in floats } ?: "right" - "
" - }, - { _ -> "
" }, - ) - ), - BLOCKQUOTE( - TextParserTagType.Direct( - true, - { _, _ -> "
" }, - { _ -> "
" } - ) - ), - - // 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("window.appendImageThumb(\"/assets/images/$imageUrl\", \"${getImageSizeStyleValue(width, height)}\");" - } - ), - MODEL( - TextParserTagType.Indirect(false) { tagParam, content, _ -> - val modelUrl = sanitizeLink(content) - - val (width, height) = getSizeParam(tagParam) - val sizeAttrs = getImageSizeAttributes(width, height) - - "" - } - ), - AUDIO( - TextParserTagType.Indirect(false) { _, content, _ -> - val audioUrl = sanitizeLink(content) - - "" - } - ), - QUIZ( - TextParserTagType.Indirect(true) { _, content, _ -> - if (content.isBlank()) - "" - else { - val contentJson = JsonStorageCodec.parseToJsonElement(TextParserState.uncensorText(content)) - "" - } - } - ), - - // Lists - UL( - TextParserTagType.Direct( - true, - { _, _ -> "
    " }, - { _ -> "
" }, - ) - ), - OL( - TextParserTagType.Direct( - true, - { _, _ -> "
    " }, - { _ -> "
" }, - ) - ), - LI( - TextParserTagType.Direct( - true, - { _, _ -> "
  • " }, - { _ -> "
  • " }, - ) - ), - - // Tables - TABLE( - TextParserTagType.Direct( - true, - { _, _ -> "" }, - { _ -> "
    " }, - ) - ), - TR( - TextParserTagType.Direct( - true, - { _, _ -> "" }, - { _ -> "" }, - ) - ), - TD( - TextParserTagType.Direct( - true, - { tagParam, _ -> - val (width, height) = getSizeParam(tagParam) - val sizeAttrs = getTableSizeAttributes(width, height) - - "" - }, - { _ -> "" }, - ) - ), - TH( - TextParserTagType.Direct( - true, - { tagParam, _ -> - val (width, height) = getSizeParam(tagParam) - val sizeAttrs = getTableSizeAttributes(width, height) - - "" - }, - { _ -> "" }, - ) - ), - - // 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() - - "" - }, - { _ -> "" }, - ) - ), - EXTLINK( - TextParserTagType.Direct( - false, - { tagParam, _ -> - val url = tagParam?.let { TextParserState.censorText(it) } - val attr = url?.let { " href=\"$it\"" }.orEmpty() - - "" - }, - { _ -> "" }, - ) - ), - ANCHOR( - TextParserTagType.Indirect(false) { _, content, _ -> - val anchor = sanitizeLink(content) - "" - } - ), - REDIRECT( - TextParserTagType.Indirect(false) { _, content, _ -> - val target = TextParserState.censorText(content) - val url = if (target.startsWith('/')) "/lore$target" else "./$target" - val string = JsonPrimitive(url).toString() - - "" - } - ), - - // 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) - "$recensored" - } else if (tagParam?.equals("thedish", ignoreCase = true) == true) - "$content" - else if (tagParam?.equals("kishari", ignoreCase = true) == true) - "$content" - 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) - "$recensored" - } else content - } - ), - ALPHABET( - TextParserTagType.Indirect(true) { _, content, _ -> - if (content.equals("mechyrdian", ignoreCase = true)) { - """ - |
    - |

    Input Text:

    - | - |

    Font options:

    - |
      - |
    • - |
    • - |
    • - |
    - |

    Rendered in Mechyrdia Sans:

    - | - |
    - """.trimMargin() - } else if (content.equals("tylan", ignoreCase = true)) { - """ - |
    - |

    Latin Alphabet:

    - | - |

    Tylan Alphabet:

    - | - |
    - """.trimMargin() - } else if (content.equals("thedish", ignoreCase = true)) { - """ - |
    - |

    Latin Alphabet:

    - | - |

    Thedish Alphabet:

    - | - |
    - """.trimMargin() - } else if (content.equals("kishari", ignoreCase = true)) { - """ - |
    - |

    Latin Alphabet:

    - | - |

    Kishari Alphabet:

    - | - |
    - """.trimMargin() - } else if (content.equals("pokhval", ignoreCase = true) || content.equals("pokhwal", ignoreCase = true)) { - """ - |
    - |

    Latin Alphabet:

    - | - |

    Pokhwalish Alphabet:

    - | - |
    - """.trimMargin() - } else "" - } - ), - VOCAB( - TextParserTagType.Indirect(true) { _, content, _ -> - if (content.isBlank()) - "" - else { - val contentJson = JsonStorageCodec.parseToJsonElement(TextParserState.uncensorText(content)) - "" - } - } - ), - ; - - companion object { - val asTags: TextParserTags by lazy { - TextParserTags(entries.associate { it.name to it.type }) - } - } -} - -enum class TextParserCommentTags(val type: TextParserTagType) { - 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() - - "" - }, - { "" }, - ) - ), - - LANG(TextParserFormattingTag.LANG.type), - - IMGBB( - TextParserTagType.Indirect(false) { tagParam, content, _ -> - val imageUrl = sanitizeExtLink(content) - val (width, height) = getSizeParam(tagParam) - - "" - } - ), - - REPLY( - TextParserTagType.Indirect(false) { _, content, _ -> - sanitizeId(content)?.let { id -> - ">>$id" - } ?: "[reply]$content[/reply]" - } - ), - - QUOTE( - TextParserTagType.Direct( - true, - { _, _ -> "
    " }, - { _ -> "
    " } - ) - ) - ; - - companion object { - val asTags: TextParserTags 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 = 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("

    ", " ").replace("

    ", " ").replace("
    ", " ").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 index fda0e86..0000000 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/parser_toc.kt +++ /dev/null @@ -1,116 +0,0 @@ -package info.mechyrdia.lore - -class TableOfContentsBuilder { - private var title: String? = null - private val levels = mutableListOf() - private val links = mutableListOf() - - 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 = listOf(NavLink("#page-top", title ?: MISSING_TITLE, aClasses = "left")) + links.toList() - - companion object { - const val MISSING_TITLE = "Untitled" - } -} - -enum class TextParserToCBuilderTag(val type: TextParserTagType) { - 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 by lazy { - TextParserFormattingTagPlainText.asTags.ignoreContext() + 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 index 0000000..f868933 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_tree.kt @@ -0,0 +1,198 @@ +package info.mechyrdia.lore + +fun String.parseAs(converter: ParserTree.() -> TSubject) = ParserState.parseText(this).converter() + +fun 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 + +sealed class ParserTreeBuilderState { + private val nodes = mutableListOf() + 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(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 index 0000000..29742b6 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/parser_utils.kt @@ -0,0 +1,38 @@ +package info.mechyrdia.lore + +fun List.splitOn(predicate: (T) -> Boolean): List> { + val whole = mutableListOf>() + val current = mutableListOf() + + 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 List.splitBefore(predicate: (T) -> Boolean): List> { + val whole = mutableListOf>() + val current = mutableListOf() + + for (item in this) { + if (predicate(item)) { + whole.add(current.toList()) + current.clear() + } + + current.add(item) + } + + whole.add(current.toList()) + return whole.toList() +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt index c598802..746744c 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/view_tpl.kt @@ -32,7 +32,7 @@ val preloadImages = listOf( "icon.png", ) -fun ApplicationCall.page(pageTitle: String, navBar: List? = null, sidebar: Sidebar? = null, ogData: OpenGraphData? = null, content: SECTIONS.() -> Unit): HTML.() -> Unit { +fun ApplicationCall.page(pageTitle: String, navBar: List? = 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? = null, sideb } } - with(sectioned()) { - content() - } + content() navBar?.let { nb -> nav(classes = "mobile") { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt index 2afad13..0be3fd5 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt @@ -101,14 +101,14 @@ fun ApplicationCall.loreRawArticlePage(pagePathParts: List): 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, 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, 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, format: } context(ApplicationCall) -private fun SECTIONS.finalSection(pagePathParts: List, canCommentAs: NationData?, comments: List, totalsData: PageVisitTotals) { +private fun SectioningOrFlowContent.finalSection(pagePathParts: List, canCommentAs: NationData?, comments: List, totalsData: PageVisitTotals) { section { h2 { a { id = "comments" } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt index f224f4f..affa3b2 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt @@ -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}", diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt b/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt index 10d0912..685cb88 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/resource_types.kt @@ -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 ) }