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
val lastEdit: Instant?,
val contentsRaw: String,
- val contentsHtml: String,
+ val contentsHtml: TagConsumer<*>.() -> Any?,
val replyLinks: List<Id<Comment>>,
) {
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(
}
div(classes = "comment") {
- unsafe { raw(comment.contentsHtml) }
+ +comment.contentsHtml
comment.lastEdit?.let { lastEdit ->
p {
style = "font-size:0.8em"
|[/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)]"
}
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 {
}
}
}
+ tr {
+ td { +"[lang=kishari]Kyşary lanur[/lang]" }
+ td {
+ +"Writes text in the Kishari alphabet: "
+ span(classes = "lang-kishari") {
+ +"kyşary lanur"
+ }
+ }
+ }
}
}
}
+++ /dev/null
-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("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- .replace("\"", """)
-
- fun uncensorText(censored: String) = censored
- .replace(""", "\"")
- .replace("<", "<")
- .replace(">", ">")
- .replace("&", "&")
-
- 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"
- }
- }
-}
--- /dev/null
+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()
+}
--- /dev/null
+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)
+}
--- /dev/null
+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
+}
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)
+}
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, "")
+++ /dev/null
-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()
-}
+++ /dev/null
-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"/> Bold</label></li>
- |<li><label><input type="checkbox" class="ital-option"/> Italic</label></li>
- |<li><label>Align: <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\">>>$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+"), " "))
+++ /dev/null
-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 })
- }
- }
-}
--- /dev/null
+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()
+ }
+ }
+}
--- /dev/null
+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()
+}
"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 }
}
}
- with(sectioned()) {
- content()
- }
+ content()
navBar?.let { nb ->
nav(classes = "mobile") {
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
}
}
}
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")
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)
}
}
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" }
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 ->
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}",
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
)
}