--- /dev/null
+package info.mechyrdia.lore
+
+import info.mechyrdia.Configuration
+import io.ktor.util.*
+import java.io.File
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+context(MutableList<File>)
+private fun ArticleNode.addPages(base: String? = null) {
+ if (!this.isViewable)
+ return
+ val path = base?.let { "$it/$name" } ?: name
+ val file = File(Configuration.CurrentConfiguration.articleDir).combineSafe(path)
+ if (file.isFile)
+ add(file)
+ else for (subNode in subNodes)
+ subNode.addPages(path)
+}
+
+private fun allPages(): List<File> {
+ return buildList {
+ for (node in rootArticleNodeList())
+ node.addPages()
+ }
+}
+
+fun Appendable.generateRecentPageEdits() {
+ val pages = allPages().sortedByDescending { it.lastModified() }
+
+ val mostRecentChange = pages.firstOrNull()?.lastModified()?.let { Instant.ofEpochMilli(it) }
+
+ RssChannel(
+ title = "Recently Edited Factbooks | The Hour of Decision",
+ link = "https://mechyrdia.info",
+ description = "A RSS news feed containing all factbooks in The Hour of Decision, in order of most recently edited.",
+ pubDate = mostRecentChange,
+ lastBuildDate = mostRecentChange,
+ ttl = 30,
+ categories = listOf(
+ RssCategory(domain = "https://nationstates.net", category = "Mechyrdia")
+ ),
+ items = pages.map { page ->
+ val pagePath = page.toRelativeString(File(Configuration.CurrentConfiguration.articleDir)).replace('\\', '/')
+
+ val pageTemplate = page.readText()
+ val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
+
+ val pageToC = TableOfContentsBuilder()
+ TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
+ val pageOg = pageToC.toOpenGraph()
+
+ val imageEnclosure = pageOg?.image?.let { url ->
+ val assetPath = url.removePrefix("https://mechyrdia.info/assets/")
+ val file = File(Configuration.CurrentConfiguration.assetDir).combineSafe(assetPath)
+ RssItemEnclosure(
+ url = url,
+ length = file.length(),
+ type = when {
+ url.endsWith(".png") -> "image/png"
+ url.endsWith(".gif") -> "image/gif"
+ url.endsWith(".jpg") || url.endsWith(".jpeg") -> "image/jpeg"
+ url.endsWith(".svg") -> "image/svg+xml"
+ else -> "application/octet-stream"
+ }
+ )
+ }
+
+ RssItem(
+ title = pageToC.toPageTitle(),
+ description = pageOg?.desc,
+ link = "https://mechyrdia.info/lore/$pagePath",
+ author = null,
+ comments = "https://mechyrdia.info/lore/$pagePath#comments",
+ enclosure = imageEnclosure,
+ pubDate = Instant.ofEpochMilli(page.lastModified())
+ )
+ }
+ ).toXml(this)
+}
+
+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>")
+ }
+}
+
+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>")
+ }
+}
+
+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()
+
+data class RssChannel(
+ val title: String,
+ val link: String,
+ val description: String,
+ val language: String? = "en-us",
+ val copyright: String? = DEFAULT_RSS_COPYRIGHT,
+ val managingEditor: String? = DEFAULT_RSS_EMAIL,
+ val webMaster: String? = managingEditor,
+ val pubDate: Instant? = null,
+ val lastBuildDate: Instant? = null,
+ val ttl: Int? = null,
+ 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>")
+ }
+}
+
+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("/>")
+ }
+}
+
+data class RssItem(
+ val title: String? = null,
+ val description: String? = null,
+ val link: String? = null,
+ val author: String? = DEFAULT_RSS_EMAIL,
+ val comments: String? = null,
+ val enclosure: RssItemEnclosure? = null,
+ val pubDate: Instant? = null,
+ val categories: List<RssCategory> = emptyList(),
+) {
+ init {
+ require(title != null || description != null) { "Either title or description must be provided, got null for both" }
+ }
+
+ 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>")
+ }
+}