Improve XML-handling code
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 14 Apr 2024 12:37:48 +0000 (08:37 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 14 Apr 2024 12:39:11 +0000 (08:39 -0400)
src/jvmMain/kotlin/info/mechyrdia/data/Xml.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsQuote.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRobots.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.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 (file)
index 0000000..071f1a7
--- /dev/null
@@ -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<String> =
+       createHTML(prettyPrint, xhtmlCompatible = true).xml()
+
+fun <O : Appendable> O.appendXml(prettyPrint: Boolean = true): XmlTagConsumer<O> =
+       appendHTML(prettyPrint, xhtmlCompatible = true).xml()
+
+@Suppress("UNCHECKED_CAST")
+fun <T, C : TagConsumer<T>> C.xml(): XmlTagConsumer<T> = if (this is XmlTagConsumer<*>)
+       this as XmlTagConsumer<T>
+else
+       XmlTagConsumerImpl(this)
+
+interface XmlTagConsumer<out R> : TagConsumer<R> {
+       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("<?xml version=\"")
+               append(version)
+               append("\" encoding=\"UTF-8\"")
+               if (standalone != null) {
+                       append(" standalone=\"")
+                       if (standalone)
+                               append("yes")
+                       else
+                               append("no")
+                       append("\"")
+               }
+               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<out R>(val downstream: TagConsumer<R>) : XmlTagConsumer<R>, TagConsumer<R> 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<String, String>? = mutableMapOf()
+       private val namespaceLookup = mutableMapOf<String, String>()
+       
+       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 <T, C : XmlTagConsumer<T>> C.declaration(version: String = "1.0", standalone: Boolean? = null) = apply {
+       onTagDeclaration(version, standalone)
+}
+
+@XmlTagMarker
+fun <T, C : XmlTagConsumer<T>> C.defaultXmlns(namespace: String) = apply {
+       onTagNamespace(null, namespace)
+}
+
+@XmlTagMarker
+fun <T, C : XmlTagConsumer<T>> C.prefixedXmlns(prefix: String, namespace: String) = apply {
+       onTagNamespace(prefix, namespace)
+}
+
+@XmlTagMarker
+fun <T, C : XmlTagConsumer<T>> C.prefixedXmlns(map: Map<String, String>) = apply {
+       for ((prefix, namespace) in map)
+               onTagNamespace(prefix, namespace)
+}
+
+@XmlTagMarker
+fun <T, C : XmlTagConsumer<T>> C.prefixedXmlns(map: Iterable<Pair<String, String>>) = apply {
+       for ((prefix, namespace) in map)
+               onTagNamespace(prefix, namespace)
+}
+
+@XmlTagMarker
+class XmlTag(
+       override val tagName: String,
+       override val consumer: XmlTagConsumer<*>,
+       initialAttributes: Map<String, String>,
+       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<Map.Entry<String, String>>
+               get() = attributes.immutableEntries
+       
+       operator fun String.invoke(attributes: Map<String, String> = 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 <T, C : XmlTagConsumer<T>> C.root(name: String, attributes: Map<String, String> = 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>.() -> String) {
+       respondText(createXml(prettyPrint).block(), contentType.withCharsetIfNeeded(Charsets.UTF_8), status)
+}
index cffd232a5ef377617cea62ed9b18db101e32d214..358951bad5c43812a0aa67c893ba1921d2518436 100644 (file)
@@ -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>(QuoteFormat.entr
 
 const val RANDOM_QUOTE_HTML_TITLE = "Random Quote"
 
-fun Quote.toXml(standalone: Boolean = true): String {
-       return buildString {
-               if (standalone)
-                       appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
-               appendLine("<quote>")
-               append("<text>").append(quote.escapeHTML()).appendLine("</text>")
-               append("<author>").append(author.escapeHTML()).appendLine("</author>")
-               append("<portrait href=\"").append(fullPortrait.escapeHTML()).appendLine("\" />")
-               append("<link href=\"").append(fullLink.escapeHTML()).appendLine("\" />")
-               appendLine("</quote>")
+fun <T, C : XmlTagConsumer<T>> 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 {
index a95aa074962361aff52ca1240c32990c10c8f1a5..cc8654521ac778a997f4139699bcfba735e285eb 100644 (file)
@@ -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<url>")
-                       appendLine("\t\t<loc>https://mechyrdia.info/lore/${page.path}</loc>")
-                       appendLine("\t\t<lastmod>$lastModified</lastmod>")
-                       appendLine("\t\t<changefreq>$AVERAGE_FACTBOOK_PAGE_CHANGEFREQ</changefreq>")
-                       appendLine("\t\t<priority>$FACTBOOK_PAGE_PRIORITY</priority>")
-                       appendLine("\t</url>")
-               }
-       }
+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<url>")
-       appendLine("\t\t<loc>https://mechyrdia.info/</loc>")
-       appendLine("\t\t<lastmod>$introLastModified</lastmod>")
-       appendLine("\t\t<changefreq>$AVERAGE_FACTBOOK_INTRO_CHANGEFREQ</changefreq>")
-       appendLine("\t\t<priority>$FACTBOOK_INTRO_PRIORITY</priority>")
-       appendLine("\t</url>")
+       return SitemapEntry(
+               loc = "https://mechyrdia.info/",
+               lastModified = introLastModified,
+               changeFrequency = AVERAGE_FACTBOOK_INTRO_CHANGEFREQ,
+               priority = FACTBOOK_INTRO_PRIORITY
+       )
 }
 
-suspend fun Appendable.generateSitemap() {
-       appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
-       appendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">")
-       renderIntroSitemap()
-       renderLoreSitemap()
-       appendLine("</urlset>")
+private suspend fun buildLoreSitemap(): List<SitemapEntry> {
+       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 <T, C : XmlTagConsumer<T>> C.sitemap(entries: List<SitemapEntry>) = declaration()
+       .defaultXmlns("http://www.sitemaps.org/schemas/sitemap/0.9")
+       .root("urlset") {
+               for (entry in entries)
+                       +entry
+       }
index c89d9759aaa7c26d330bcdff92da12b99226f9c9..ef674d9c274fbcc37a0c6e75edf698457b5ad6b3 100644 (file)
@@ -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<StoragePathWithStat> {
@@ -44,12 +47,12 @@ suspend fun allPages(): List<StoragePathWithStat> {
        }
 }
 
-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("<category")
-               if (domain == null)
-                       appendable.append(">")
-               else
-                       appendable.append(" domain=\"").append(domain.escapeHTML()).append("\">")
-               appendable.append(category.escapeHTML()).appendLine("</category>")
-       }
+)
+
+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("<image>")
-               appendable.append("<url>").append(url.escapeHTML()).appendLine("</url>")
-               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
-               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
-               appendable.appendLine("</image>")
-       }
+)
+
+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<RssCategory> = emptyList(),
        val items: List<RssItem> = emptyList(),
-) {
-       fun toXml(appendable: Appendable) {
-               appendable.appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
-               appendable.appendLine("<rss version=\"2.0\">")
-               appendable.appendLine("<channel>")
-               
-               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
-               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
-               appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
-               
-               if (language != null)
-                       appendable.append("<language>").append(language.escapeHTML()).appendLine("</language>")
-               if (copyright != null)
-                       appendable.append("<copyright>").append(copyright.escapeHTML()).appendLine("</copyright>")
-               if (managingEditor != null)
-                       appendable.append("<managingEditor>").append(managingEditor.escapeHTML()).appendLine("</managingEditor>")
-               if (webMaster != null)
-                       appendable.append("<webMaster>").append(webMaster.escapeHTML()).appendLine("</webMaster>")
-               if (pubDate != null)
-                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
-               if (lastBuildDate != null)
-                       appendable.append("<lastBuildDate>").append(lastBuildDate.toXml()).appendLine("</lastBuildDate>")
-               if (ttl != null)
-                       appendable.append("<ttl>").append(ttl.toString()).appendLine("</ttl>")
-               
-               image?.toXml(appendable)
-               
-               for (category in categories)
-                       category.toXml(appendable)
-               for (item in items)
-                       item.toXml(appendable)
-               
-               appendable.appendLine("</channel>")
-               appendable.appendLine("</rss>")
+)
+
+fun <T, C : XmlTagConsumer<T>> 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("<enclosure ")
-                       .append("url=\"").append(url.escapeHTML()).append("\" ")
-                       .append("length=\"").append(length.toString()).append("\" ")
-                       .append("type=\"").append(type.escapeHTML()).append("\" ")
-                       .appendLine("/>")
-       }
-}
+)
+
+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("<item>")
-               
-               if (title != null)
-                       appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
-               if (description != null)
-                       appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
-               if (link != null)
-                       appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
-               if (author != null)
-                       appendable.append("<author>").append(author.escapeHTML()).appendLine("</author>")
-               if (comments != null)
-                       appendable.append("<comments>").append(comments.escapeHTML()).appendLine("</comments>")
-               enclosure?.toXml(appendable)
-               
-               if (pubDate != null)
-                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
-               
-               for (category in categories)
-                       category.toXml(appendable)
-               
-               appendable.appendLine("</item>")
-       }
+       for (category in categories)
+               +category
 }
index f1a8d852e1e8af3155594f65668b4ab8d1ecaafd..5ac391cc9abad08dc2a5317bb615cc50b61b7955 100644 (file)
@@ -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<String>("ErrorMessage")
 
@@ -81,7 +79,10 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
                override suspend fun PipelineContext<Unit, ApplicationCall>.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<Unit, ApplicationCall>.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<Unit, ApplicationCall>.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<Unit, ApplicationCall>.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)
+                               }
                        }
                }