From faf10b4e199d5d6de975db434707f6514f09c17d Mon Sep 17 00:00:00 2001 From: Lanius Trolling Date: Sun, 14 Apr 2024 08:37:48 -0400 Subject: [PATCH] Improve XML-handling code --- src/jvmMain/kotlin/info/mechyrdia/data/Xml.kt | 215 ++++++++++++++++++ .../kotlin/info/mechyrdia/lore/ViewsQuote.kt | 23 +- .../kotlin/info/mechyrdia/lore/ViewsRobots.kt | 105 ++++++--- .../kotlin/info/mechyrdia/lore/ViewsRss.kt | 170 +++++++------- .../info/mechyrdia/route/ResourceTypes.kt | 18 +- 5 files changed, 379 insertions(+), 152 deletions(-) create mode 100644 src/jvmMain/kotlin/info/mechyrdia/data/Xml.kt diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/Xml.kt b/src/jvmMain/kotlin/info/mechyrdia/data/Xml.kt new file mode 100644 index 0000000..071f1a7 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/data/Xml.kt @@ -0,0 +1,215 @@ +package info.mechyrdia.data + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import kotlinx.html.Tag +import kotlinx.html.TagConsumer +import kotlinx.html.consumers.DelayedConsumer +import kotlinx.html.consumers.FinalizeConsumer +import kotlinx.html.consumers.TraceConsumer +import kotlinx.html.dom.HTMLDOMBuilder +import kotlinx.html.impl.DelegatingMap +import kotlinx.html.org.w3c.dom.events.Event +import kotlinx.html.stream.HTMLStreamBuilder +import kotlinx.html.stream.appendHTML +import kotlinx.html.stream.createHTML +import kotlinx.html.visit +import kotlinx.html.visitAndFinalize +import org.w3c.dom.Document +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.contains +import kotlin.collections.set + +@DslMarker +annotation class XmlTagMarker + +fun createXml(prettyPrint: Boolean = true): XmlTagConsumer = + createHTML(prettyPrint, xhtmlCompatible = true).xml() + +fun O.appendXml(prettyPrint: Boolean = true): XmlTagConsumer = + appendHTML(prettyPrint, xhtmlCompatible = true).xml() + +@Suppress("UNCHECKED_CAST") +fun > C.xml(): XmlTagConsumer = if (this is XmlTagConsumer<*>) + this as XmlTagConsumer +else + XmlTagConsumerImpl(this) + +interface XmlTagConsumer : TagConsumer { + fun onTagDeclaration(version: String, standalone: Boolean?) + fun onTagDeclaration(standalone: Boolean?) = onTagDeclaration("1.0", standalone) + + fun onTagNamespace(prefix: String?, namespace: String) + + override fun onTagEvent(tag: Tag, event: String, value: (Event) -> Unit) { + tagEventsNotSupported() + } + + fun tagEventsNotSupported(): Nothing { + throw UnsupportedOperationException("Events are not supported in XML") + } +} + +private fun interface XmlDeclarationConsumer { + fun consumeDeclaration(version: String, standalone: Boolean?) +} + +private val Appendable.declarationConsumer: XmlDeclarationConsumer + get() = XmlDeclarationConsumer { version, standalone -> + append("") + appendLine() + } + +private val Document.declarationConsumer: XmlDeclarationConsumer + get() = XmlDeclarationConsumer { version, standalone -> + xmlVersion = version + xmlStandalone = standalone == true + } + +private fun TagConsumer<*>.getDeclarationConsumer(): XmlDeclarationConsumer = + when (this) { + is HTMLStreamBuilder<*> -> out.declarationConsumer + is HTMLDOMBuilder -> document.declarationConsumer + is DelayedConsumer<*> -> downstream.getDeclarationConsumer() + is FinalizeConsumer<*, *> -> downstream.getDeclarationConsumer() + is TraceConsumer<*> -> downstream.getDeclarationConsumer() + is XmlTagConsumerImpl<*> -> downstream.getDeclarationConsumer() + else -> throw IllegalArgumentException("Unsupported TagConsumer subtype ${this::class.qualifiedName}") + } + +private data class XmlTagImpl(val tag: Tag, val prefix: String?) : Tag by tag { + override val namespace: String? + get() = null + + override val tagName: String + get() = prefix?.let { "$it:" }.orEmpty() + tag.tagName +} + +private class XmlTagConsumerImpl(val downstream: TagConsumer) : XmlTagConsumer, TagConsumer by downstream { + private var isDeclared = false + + override fun onTagDeclaration(version: String, standalone: Boolean?) { + if (isDeclared) + error("Cannot write XML declaration twice") + + downstream.getDeclarationConsumer().consumeDeclaration(version, standalone) + isDeclared = true + } + + private var defaultNamespace: String? = null + private var namespaces: MutableMap? = mutableMapOf() + private val namespaceLookup = mutableMapOf() + + override fun onTagNamespace(prefix: String?, namespace: String) { + if (prefix == null) { + if (namespaces == null) + error("Unable to add xmlns attribute after document has already started") + defaultNamespace = namespace + } else + (namespaces + ?: error("Unable to add xmlns attribute after document has already started"))[prefix] = namespace + } + + override fun onTagStart(tag: Tag) { + namespaces?.let { namespaceList -> + defaultNamespace?.let { namespaceDefault -> + tag.attributes["xmlns"] = namespaceDefault + } + + for ((prefix, namespace) in namespaceList) { + tag.attributes["xmlns:$prefix"] = namespace + namespaceLookup[namespace] = prefix + } + + namespaces = null + } + + val prefix = when (tag.namespace) { + null, defaultNamespace -> null + in namespaceLookup -> namespaceLookup[tag.namespace] + else -> throw IllegalArgumentException("Unrecognized namespace ${tag.namespace}") + } + + downstream.onTagStart(XmlTagImpl(tag, prefix)) + } + + override fun onTagEnd(tag: Tag) { + val prefix = when (tag.namespace) { + null, defaultNamespace -> null + in namespaceLookup -> namespaceLookup[tag.namespace] + else -> throw IllegalArgumentException("Unrecognized namespace ${tag.namespace}") + } + + downstream.onTagEnd(XmlTagImpl(tag, prefix)) + } + + override fun onTagEvent(tag: Tag, event: String, value: (Event) -> Unit) { + tagEventsNotSupported() + } +} + +@XmlTagMarker +fun > C.declaration(version: String = "1.0", standalone: Boolean? = null) = apply { + onTagDeclaration(version, standalone) +} + +@XmlTagMarker +fun > C.defaultXmlns(namespace: String) = apply { + onTagNamespace(null, namespace) +} + +@XmlTagMarker +fun > C.prefixedXmlns(prefix: String, namespace: String) = apply { + onTagNamespace(prefix, namespace) +} + +@XmlTagMarker +fun > C.prefixedXmlns(map: Map) = apply { + for ((prefix, namespace) in map) + onTagNamespace(prefix, namespace) +} + +@XmlTagMarker +fun > C.prefixedXmlns(map: Iterable>) = apply { + for ((prefix, namespace) in map) + onTagNamespace(prefix, namespace) +} + +@XmlTagMarker +class XmlTag( + override val tagName: String, + override val consumer: XmlTagConsumer<*>, + initialAttributes: Map, + override val namespace: String? = null, + override val inlineTag: Boolean, + override val emptyTag: Boolean +) : Tag { + override val attributes: DelegatingMap = DelegatingMap(initialAttributes, this) { consumer } + + override val attributesEntries: Collection> + get() = attributes.immutableEntries + + operator fun String.invoke(attributes: Map = emptyMap(), namespace: String? = null, isInline: Boolean = false, block: (XmlTag.() -> Unit)? = null) = XmlTag(this, consumer, attributes, namespace, isInline, block == null).visit(block ?: emptyBlock) +} + +private val emptyBlock: XmlTag.() -> Unit = {} + +@XmlTagMarker +fun > C.root(name: String, attributes: Map = emptyMap(), namespace: String? = null, block: (XmlTag.() -> Unit)? = null) = XmlTag(name, this, attributes, namespace, false, block == null).visitAndFinalize(this, block ?: emptyBlock) + +suspend fun ApplicationCall.respondXml(status: HttpStatusCode? = null, contentType: ContentType = ContentType.Text.Xml, prettyPrint: Boolean = true, block: XmlTagConsumer.() -> String) { + respondText(createXml(prettyPrint).block(), contentType.withCharsetIfNeeded(Charsets.UTF_8), status) +} diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt index cffd232..358951b 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt @@ -1,14 +1,12 @@ package info.mechyrdia.lore import info.mechyrdia.JsonFileCodec -import info.mechyrdia.data.FileStorage -import info.mechyrdia.data.StoragePath +import info.mechyrdia.data.* import info.mechyrdia.route.KeyedEnumSerializer import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.html.* import io.ktor.server.response.* -import io.ktor.util.* import kotlinx.html.* import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer @@ -64,7 +62,7 @@ enum class QuoteFormat(val format: String?) { }, XML("xml") { override suspend fun ApplicationCall.respondQuote(quote: Quote) { - respondText(quote.toXml(), contentType = ContentType.Application.Xml) + respondXml { quote(quote) } } }, ; @@ -76,18 +74,13 @@ object QuoteFormatSerializer : KeyedEnumSerializer(QuoteFormat.entr const val RANDOM_QUOTE_HTML_TITLE = "Random Quote" -fun Quote.toXml(standalone: Boolean = true): String { - return buildString { - if (standalone) - appendLine("") - appendLine("") - append("").append(quote.escapeHTML()).appendLine("") - append("").append(author.escapeHTML()).appendLine("") - append("") - append("") - appendLine("") +fun > C.quote(quote: Quote) = declaration(standalone = true) + .root("quote") { + "text" { +quote.quote } + "author" { +quote.author } + "portrait"(attributes = mapOf("href" to quote.fullPortrait)) + "link"(attributes = mapOf("href" to quote.fullLink)) } -} fun Quote.toJson(): String { return buildJsonObject { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt index a95aa07..cc86545 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt @@ -1,46 +1,79 @@ package info.mechyrdia.lore -import info.mechyrdia.data.FileStorage -import info.mechyrdia.data.StoragePath - -private const val AVERAGE_FACTBOOK_INTRO_CHANGEFREQ = "daily" -private const val AVERAGE_FACTBOOK_PAGE_CHANGEFREQ = "hourly" -private const val FACTBOOK_INTRO_PRIORITY = "0.7" -private const val FACTBOOK_PAGE_PRIORITY = "0.8" - -private suspend fun Appendable.renderLoreSitemap() { - for (page in allPages()) { - if (page.path.isViewable) { - val lastModified = page.stat.updated - - appendLine("\t") - appendLine("\t\thttps://mechyrdia.info/lore/${page.path}") - appendLine("\t\t$lastModified") - appendLine("\t\t$AVERAGE_FACTBOOK_PAGE_CHANGEFREQ") - appendLine("\t\t$FACTBOOK_PAGE_PRIORITY") - appendLine("\t") - } - } +import info.mechyrdia.data.* +import java.time.Instant +import java.time.format.DateTimeFormatter + +private val AVERAGE_FACTBOOK_INTRO_CHANGEFREQ = SitemapChangeFrequency.DAILY +private val AVERAGE_FACTBOOK_PAGE_CHANGEFREQ = SitemapChangeFrequency.HOURLY +private const val FACTBOOK_INTRO_PRIORITY = 0.5 +private const val FACTBOOK_PAGE_PRIORITY = 0.75 + +enum class SitemapChangeFrequency { + NEVER, + YEARLY, + MONTHLY, + WEEKLY, + DAILY, + HOURLY, + ALWAYS; } -private suspend fun Appendable.renderIntroSitemap() { - val introFile = FileStorage.instance.statFile(StoragePath.Root / "intro.html") ?: return - val introMetaFile = FileStorage.instance.statFile(StoragePath.Root / "introMeta.json") ?: return +val SitemapChangeFrequency.xmlValue: String + get() = name.lowercase() + +val Instant.xmlValue: String + get() = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(this) + +val Double.xmlValue: String + get() = "%f".format(this) + +data class SitemapEntry( + val loc: String, + val lastModified: Instant, + val changeFrequency: SitemapChangeFrequency, + val priority: Double, +) + +context(XmlTag) +operator fun SitemapEntry.unaryPlus() = "url" { + "loc" { +loc } + "lastmod" { +lastModified.xmlValue } + "changefreq" { +changeFrequency.xmlValue } + "priority" { +priority.xmlValue } +} + +private suspend fun buildIntroSitemap(): SitemapEntry? { + val introFile = FileStorage.instance.statFile(StoragePath.Root / "intro.html") ?: return null + val introMetaFile = FileStorage.instance.statFile(StoragePath.Root / "introMeta.json") ?: return null val introLastModified = maxOf(introFile.updated, introMetaFile.updated) - appendLine("\t") - appendLine("\t\thttps://mechyrdia.info/") - appendLine("\t\t$introLastModified") - appendLine("\t\t$AVERAGE_FACTBOOK_INTRO_CHANGEFREQ") - appendLine("\t\t$FACTBOOK_INTRO_PRIORITY") - appendLine("\t") + return SitemapEntry( + loc = "https://mechyrdia.info/", + lastModified = introLastModified, + changeFrequency = AVERAGE_FACTBOOK_INTRO_CHANGEFREQ, + priority = FACTBOOK_INTRO_PRIORITY + ) } -suspend fun Appendable.generateSitemap() { - appendLine("") - appendLine("") - renderIntroSitemap() - renderLoreSitemap() - appendLine("") +private suspend fun buildLoreSitemap(): List { + return allPages().mapNotNull { page -> + if (!page.path.isViewable) null + else SitemapEntry( + loc = "https://mechyrdia.info/lore/${page.path}", + lastModified = page.stat.updated, + changeFrequency = AVERAGE_FACTBOOK_PAGE_CHANGEFREQ, + priority = FACTBOOK_PAGE_PRIORITY + ) + } } + +suspend fun buildSitemap() = listOfNotNull(buildIntroSitemap()) + buildLoreSitemap() + +fun > C.sitemap(entries: List) = declaration() + .defaultXmlns("http://www.sitemaps.org/schemas/sitemap/0.9") + .root("urlset") { + for (entry in entries) + +entry + } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt index c89d975..ef674d9 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt @@ -5,7 +5,6 @@ import info.mechyrdia.OwnerNationId import info.mechyrdia.data.* import io.ktor.http.* import io.ktor.server.application.* -import io.ktor.util.* import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -16,6 +15,10 @@ import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter +suspend fun ApplicationCall.respondRss(rss: RssChannel) { + respondXml(contentType = ContentType.Application.Rss) { rss(rss) } +} + data class StoragePathWithStat(val path: StoragePath, val stat: StoredFileStats) private suspend fun ArticleNode.getPages(base: StoragePath): List { @@ -44,12 +47,12 @@ suspend fun allPages(): List { } } -suspend fun Appendable.generateRecentPageEdits() { +suspend fun generateRecentPageEdits(): RssChannel { val pages = allPages().sortedByDescending { it.stat.updated } val mostRecentChange = pages.firstOrNull()?.stat?.updated - RssChannel( + return RssChannel( title = "Recently Edited Factbooks | The Hour of Decision", link = "https://mechyrdia.info", description = "An RSS feed containing all factbooks in The Hour of Decision, in order of most recently edited.", @@ -91,15 +94,17 @@ suspend fun Appendable.generateRecentPageEdits() { } }.awaitAll().filterNotNull() } - ).toXml(this) + ) } -suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): Appendable.() -> Unit { +suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): RssChannel { val currNation = currentNation() val validLimits = 1..100 - if (limit !in validLimits) + if (limit !in validLimits) { + response.status(HttpStatusCode.BadRequest) + return RssChannel( title = "Recent Comments - Error | The Hour of Decision", link = "https://mechyrdia.info/comment/recent", @@ -107,7 +112,8 @@ suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): Appendab pubDate = null, lastBuildDate = Instant.now(), ttl = 120, - )::toXml + ) + } val comments = CommentRenderData( Comment.Table @@ -149,42 +155,39 @@ suspend fun ApplicationCall.recentCommentsRssFeedGenerator(limit: Int): Appendab ) ) } - )::toXml + ) } data class RssCategory( val category: String, val domain: String? = null -) { - fun toXml(appendable: Appendable) { - appendable.append("") - else - appendable.append(" domain=\"").append(domain.escapeHTML()).append("\">") - appendable.append(category.escapeHTML()).appendLine("") - } +) + +context(XmlTag) +operator fun RssCategory.unaryPlus() = "category" { + if (domain != null) attributes["domain"] = domain + +category } data class RssChannelImage( val url: String, val title: String, val link: String, -) { - fun toXml(appendable: Appendable) { - appendable.appendLine("") - appendable.append("").append(url.escapeHTML()).appendLine("") - appendable.append("").append(title.escapeHTML()).appendLine("") - appendable.append("").append(link.escapeHTML()).appendLine("") - appendable.appendLine("") - } +) + +context(XmlTag) +operator fun RssChannelImage.unaryPlus() = "image" { + "url" { +url } + "title" { +title } + "link" { +link } } const val DEFAULT_RSS_COPYRIGHT = "Copyright 2022 Lanius Trolling" const val DEFAULT_RSS_EMAIL = "lanius@laniustrolling.dev (Lanius Trolling)" val RssDateFormat: DateTimeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME -fun Instant.toXml() = RssDateFormat.format(atOffset(ZoneOffset.UTC)).escapeHTML() +val Instant.rssValue: String + get() = RssDateFormat.format(atOffset(ZoneOffset.UTC)) data class RssChannel( val title: String, @@ -200,56 +203,47 @@ data class RssChannel( val image: RssChannelImage? = null, val categories: List = emptyList(), val items: List = emptyList(), -) { - fun toXml(appendable: Appendable) { - appendable.appendLine("") - appendable.appendLine("") - appendable.appendLine("") - - appendable.append("").append(title.escapeHTML()).appendLine("") - appendable.append("").append(link.escapeHTML()).appendLine("") - appendable.append("").append(description.escapeHTML()).appendLine("") - - if (language != null) - appendable.append("").append(language.escapeHTML()).appendLine("") - if (copyright != null) - appendable.append("").append(copyright.escapeHTML()).appendLine("") - if (managingEditor != null) - appendable.append("").append(managingEditor.escapeHTML()).appendLine("") - if (webMaster != null) - appendable.append("").append(webMaster.escapeHTML()).appendLine("") - if (pubDate != null) - appendable.append("").append(pubDate.toXml()).appendLine("") - if (lastBuildDate != null) - appendable.append("").append(lastBuildDate.toXml()).appendLine("") - if (ttl != null) - appendable.append("").append(ttl.toString()).appendLine("") - - image?.toXml(appendable) - - for (category in categories) - category.toXml(appendable) - for (item in items) - item.toXml(appendable) - - appendable.appendLine("") - appendable.appendLine("") +) + +fun > C.rss(rssChannel: RssChannel) = declaration() + .root("rss") { + attributes["version"] = "2.0" + "channel" { + "title" { +rssChannel.title } + "link" { +rssChannel.link } + "description" { +rssChannel.description } + + if (rssChannel.language != null) "language" { +rssChannel.language } + if (rssChannel.copyright != null) "copyright" { +rssChannel.copyright } + if (rssChannel.managingEditor != null) "managingEditor" { +rssChannel.managingEditor } + if (rssChannel.webMaster != null) "webMaster" { +rssChannel.webMaster } + if (rssChannel.pubDate != null) "pubDate" { +rssChannel.pubDate.rssValue } + if (rssChannel.lastBuildDate != null) "lastBuildDate" { +rssChannel.lastBuildDate.rssValue } + if (rssChannel.ttl != null) "ttl" { +rssChannel.ttl.toString() } + + if (rssChannel.image != null) +rssChannel.image + + for (category in rssChannel.categories) + +category + for (item in rssChannel.items) + +item + } } -} data class RssItemEnclosure( val url: String, val length: Long, val type: String, -) { - fun toXml(appendable: Appendable) { - appendable.append("") - } -} +) + +context(XmlTag) +operator fun RssItemEnclosure.unaryPlus() = "enclosure"( + attributes = mapOf( + "url" to url, + "length" to length.toString(), + "type" to type, + ) +) data class RssItem( val title: String? = null, @@ -264,28 +258,18 @@ data class RssItem( init { require(title != null || description != null) { "Either title or description must be provided, got null for both" } } +} + +context(XmlTag) +operator fun RssItem.unaryPlus() = "item" { + if (title != null) "title" { +title } + if (description != null) "description" { +description } + if (link != null) "link" { +link } + if (author != null) "author" { +author } + if (comments != null) "comments" { +comments } + if (enclosure != null) +enclosure + if (pubDate != null) "pubDate" { +pubDate.rssValue } - fun toXml(appendable: Appendable) { - appendable.appendLine("") - - if (title != null) - appendable.append("").append(title.escapeHTML()).appendLine("") - if (description != null) - appendable.append("").append(description.escapeHTML()).appendLine("") - if (link != null) - appendable.append("").append(link.escapeHTML()).appendLine("") - if (author != null) - appendable.append("").append(author.escapeHTML()).appendLine("") - if (comments != null) - appendable.append("").append(comments.escapeHTML()).appendLine("") - enclosure?.toXml(appendable) - - if (pubDate != null) - appendable.append("").append(pubDate.toXml()).appendLine("") - - for (category in categories) - category.toXml(appendable) - - appendable.appendLine("") - } + for (category in categories) + +category } diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt index f1a8d85..5ac391c 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -14,9 +14,7 @@ import io.ktor.server.plugins.* import io.ktor.server.response.* import io.ktor.util.* import io.ktor.util.pipeline.* -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext val ErrorMessageAttributeKey = AttributeKey("ErrorMessage") @@ -81,7 +79,10 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter { override suspend fun PipelineContext.handleCall() { with(root) { filterCall() } - call.respondText(buildString { generateSitemap() }, ContentType.Application.Xml) + val sitemap = buildSitemap() + call.respondXml(contentType = ContentType.Application.Xml) { + sitemap(sitemap) + } } } @@ -90,7 +91,7 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter { override suspend fun PipelineContext.handleCall() { with(root) { filterCall() } - call.respondText(buildString { generateRecentPageEdits() }, ContentType.Application.Rss) + call.respondRss(generateRecentPageEdits()) } } @@ -99,7 +100,7 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter { override suspend fun PipelineContext.handleCall() { with(root) { filterCall() } - call.respondText(buildString(call.recentCommentsRssFeedGenerator(limit ?: 10)), ContentType.Application.Rss) + call.respondRss(call.recentCommentsRssFeedGenerator(limit ?: 10)) } } @@ -411,9 +412,10 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter { override suspend fun PipelineContext.handleCall(payload: MechyrdiaSansPayload) { with(utils) { filterCall() } - call.respondText(withContext(Dispatchers.Default) { - MechyrdiaSansFont.renderTextToSvg(payload.lines.joinToString(separator = "\n") { it.trim() }, payload.bold, payload.italic, payload.align) - }, ContentType.Image.SVG) + val svgDoc = MechyrdiaSansFont.renderTextToSvg(payload.lines.joinToString(separator = "\n") { it.trim() }, payload.bold, payload.italic, payload.align) + call.respondXml(contentType = ContentType.Image.SVG) { + svg(svgDoc) + } } } -- 2.25.1