call.respondHtml(HttpStatusCode.OK, call.loreArticlePage())
}
+ get("/lore.raw") {
+ call.respondHtml(HttpStatusCode.OK, call.loreRawArticlePage(""))
+ }
+
staticFiles("/assets", File(Configuration.CurrentConfiguration.assetDir), index = null) {
enableAutoHeadResponse()
}
with(call) { respondHtml(HttpStatusCode.OK, randomQuote().toHtml("Random Quote")) }
}
+ get("/quote.raw") {
+ with(call) { respondHtml(HttpStatusCode.OK, randomQuote().toRawHtml("Random Quote")) }
+ }
+
get("/quote.json") {
call.respondText(randomQuote().toJson(), ContentType.Application.Json)
}
val File.isViewable: Boolean
get() = name.isViewable
-fun List<ArticleNode>.renderInto(list: UL, base: String? = null) {
+fun List<ArticleNode>.renderInto(list: UL, base: String? = null, suffix: String = "") {
val prefix by lazy(LazyThreadSafetyMode.NONE) { base?.let { "$it/" } ?: "" }
for (node in this) {
if (node.isViewable)
list.li {
- a(href = "/lore/$prefix${node.name}") { +node.name }
+ a(href = "/lore/$prefix${node.name}$suffix") { +node.name }
if (node.subNodes.isNotEmpty())
ul {
- node.subNodes.renderInto(this, "$prefix${node.name}")
+ node.subNodes.renderInto(this, "$prefix${node.name}", suffix)
}
}
}
--- /dev/null
+package info.mechyrdia.lore
+
+import info.mechyrdia.Configuration
+import io.ktor.util.*
+import java.io.File
+
+fun String.toRawLink() = substringBeforeLast('#') + ".raw"
+
+enum class TextParserRawPageTag(val type: TextParserTagType<Unit>) {
+ IPA(
+ TextParserTagType.Direct(
+ false,
+ { _, _ -> "" },
+ { _ -> "" },
+ )
+ ),
+ CODE(
+ TextParserTagType.Direct(
+ false,
+ { _, _ -> "<span style='font-family: monospace'>" },
+ { _ -> "</span>" },
+ )
+ ),
+ CODE_BLOCK(
+ TextParserTagType.Direct(
+ false,
+ { _, _ -> "<div style='font-family: monospace'><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>" }
+ )
+ ),
+ ASIDE(
+ TextParserTagType.Direct(
+ true,
+ { tagParam, _ ->
+ val floats = setOf("left", "right")
+ val float = tagParam?.takeIf { it in floats } ?: "right"
+ "<div style='float: $float; max-width: 50vw'>"
+ },
+ { _ -> "</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()
+ .replace("<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>"
+ }
+ ),
+ TABLE(
+ TextParserTagType.Direct(
+ true,
+ { _, _ -> "<table style='width: 100%; table-layout: fixed'>" },
+ { _ -> "</table>" },
+ )
+ ),
+ TD(
+ TextParserTagType.Direct(
+ true,
+ { tagParam, _ ->
+ val (width, height) = getSizeParam(tagParam)
+ val sizeAttrs = getTableSizeAttributes(width, height)
+
+ "<td$sizeAttrs style='border: 1px solid #555'>"
+ },
+ { _ -> "</td>" },
+ )
+ ),
+ TH(
+ TextParserTagType.Direct(
+ true,
+ { tagParam, _ ->
+ val (width, height) = getSizeParam(tagParam)
+ val sizeAttrs = getTableSizeAttributes(width, height)
+
+ "<th$sizeAttrs style='border: 1px solid #222'>"
+ },
+ { _ -> "</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()}\"" } ?: ""
+
+ "<a$attr>"
+ },
+ { _ -> "</a>" },
+ )
+ ),
+ REDIRECT(
+ TextParserTagType.Indirect(false) { _, content, _ ->
+ val target = TextParserState.censorText(content)
+ val url = if (target.startsWith('/')) "/lore$target" else "./$target"
+
+ "<a href='${url.toRawLink()}'>Click here for a manual redirect</a>"
+ }
+ ),
+ LANG(
+ TextParserTagType.Direct(
+ false,
+ { _, _ -> "<span style='font-style: italic'>" },
+ { _ -> "</span>" }
+ )
+ ),
+ ALPHABET(
+ TextParserTagType.Indirect(true) { _, _, _ ->
+ "<p>Unfortunately, raw view does not support interactive conscript previews</p>"
+ }
+ ),
+ VOCAB(
+ TextParserTagType.Indirect(true) { _, _, _ ->
+ "<p>Unfortunately, raw view does not support interactive conlang dictionaries</p>"
+ }
+ ),
+ ;
+
+ companion object {
+ val asTags: TextParserTags<Unit> by lazy {
+ TextParserFormattingTag.asTags + TextParserTags(entries.associate { it.name to it.type })
+ }
+ }
+}
+
+fun getRawImageSizeStyleValue(width: Int?, height: Int?) = (width?.let { "width: ${it * 0.25}px;" } ?: "") + (height?.let { "height: ${it * 0.25}px;" } ?: "")
+fun getRawImageSizeAttributes(width: Int?, height: Int?) = " style=\"${getRawImageSizeStyleValue(width, height)}\""
val alignments = setOf("left", "center", "right", "justify")
val alignment = tagParam?.takeIf { it in alignments }
val styleAttr = alignment?.let { " style=\"text-align: $it\"" } ?: ""
- "<div$styleAttr>"
+ "<p$styleAttr>"
},
- { _ -> "</div>" },
+ { _ -> "</p>" },
)
),
ASIDE(
}
}
}
+
+fun ApplicationCall.rawPage(pageTitle: String, ogData: OpenGraphData? = null, content: BODY.() -> Unit): HTML.() -> Unit {
+ return {
+ lang = "en"
+
+ head {
+ meta(charset = "utf-8")
+ meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
+
+ meta(name = "theme-color", content = "#FFCC33")
+
+ ogData?.let { data ->
+ renderOgData(pageTitle, data)
+ }
+
+ link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.png")
+
+ title {
+ +pageTitle
+ }
+ }
+ body {
+ content()
+ }
+ }
+}
import io.ktor.server.request.*
import kotlinx.html.*
-suspend fun ApplicationCall.error400(): HTML.() -> Unit = page("400 Bad Request", standardNavBar()) {
- section {
- h1 { +"400 Bad Request" }
- p { +"The request your browser sent was improperly formatted." }
- }
+suspend fun ApplicationCall.errorPage(title: String, body: FlowContent.() -> Unit): HTML.() -> Unit {
+ return if (request.path().endsWith(".raw"))
+ rawPage(title) {
+ body()
+ }
+ else
+ page(title, standardNavBar()) {
+ section {
+ body()
+ }
+ }
}
-suspend fun ApplicationCall.error403(): HTML.() -> Unit = page("403 Forbidden", standardNavBar()) {
- section {
- h1 { +"403 Forbidden" }
- p { +"You are not allowed to do that." }
- }
+suspend fun ApplicationCall.error400(): HTML.() -> Unit = errorPage("400 Bad Request") {
+ h1 { +"400 Bad Request" }
+ p { +"The request your browser sent was improperly formatted." }
}
-suspend fun ApplicationCall.error403PageExpired(formData: Parameters): HTML.() -> Unit = page("Page Expired", standardNavBar()) {
- section {
- h1 { +"Page Expired" }
- formData["comment"]?.let { commentData ->
- p { +"The comment you tried to submit had been preserved here:" }
- textArea {
- readonly = true
- +commentData
- }
+suspend fun ApplicationCall.error403(): HTML.() -> Unit = errorPage("403 Forbidden") {
+ h1 { +"403 Forbidden" }
+ p { +"You are not allowed to do that." }
+}
+
+suspend fun ApplicationCall.error403PageExpired(formData: Parameters): HTML.() -> Unit = errorPage("Page Expired") {
+ h1 { +"Page Expired" }
+ formData["comment"]?.let { commentData ->
+ p { +"The comment you tried to submit had been preserved here:" }
+ textArea {
+ readonly = true
+ +commentData
}
- p {
- +"The page you were on has expired."
- request.header(HttpHeaders.Referrer)?.let { referrer ->
- +" You can "
- a(href = referrer) { +"return to the previous page" }
- +" and retry your action."
- }
+ }
+ p {
+ +"The page you were on has expired."
+ request.header(HttpHeaders.Referrer)?.let { referrer ->
+ +" You can "
+ a(href = referrer) { +"return to the previous page" }
+ +" and retry your action."
}
}
}
-suspend fun ApplicationCall.error404(): HTML.() -> Unit = page("404 Not Found", standardNavBar()) {
- section {
- h1 { +"404 Not Found" }
- p {
- +"Unfortunately, we could not find what you were looking for. Would you like to "
- a(href = "/") { +"return to the index page" }
- +"?"
- }
+suspend fun ApplicationCall.error404(): HTML.() -> Unit = errorPage("404 Not Found") {
+ h1 { +"404 Not Found" }
+ p {
+ +"Unfortunately, we could not find what you were looking for. Would you like to "
+ a(href = "/") { +"return to the index page" }
+ +"?"
}
}
-suspend fun ApplicationCall.error500(): HTML.() -> Unit = page("500 Internal Error", standardNavBar()) {
- section {
- h1 { +"500 Internal Error" }
- p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." }
- }
+suspend fun ApplicationCall.error500(): HTML.() -> Unit = errorPage("500 Internal Error") {
+ h1 { +"500 Internal Error" }
+ p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." }
}
}
}
+private fun FlowContent.breadCrumbs(links: List<Pair<String, String>>) = p {
+ var isNext = false
+ for ((url, text) in links) {
+ if (isNext) {
+ +Entities.nbsp
+ +Entities.gt
+ +Entities.nbsp
+ } else isNext = true
+
+ a(href = url) { +text }
+ }
+}
+
+fun ApplicationCall.loreRawArticlePage(rawPagePath: String): HTML.() -> Unit {
+ val articleDir = File(Configuration.CurrentConfiguration.articleDir)
+
+ val pagePath = rawPagePath.removeSuffix(".raw")
+ val pageFile = if (pagePath.isEmpty()) articleDir else articleDir.combineSafe(pagePath)
+ val pageNode = pageFile.toArticleNode()
+
+ val parentPaths = if (pagePath.isEmpty())
+ emptyList()
+ else {
+ val pathParts = pagePath.split('/').dropLast(1)
+ listOf("/lore.raw" to "Table of Contents") + pathParts.mapIndexed { i, part ->
+ pathParts.take(i + 1).joinToString(separator = "/", prefix = "/lore/", postfix = ".raw") to part.toFriendlyPageTitle()
+ }
+ }
+
+ val isValid = pageFile.exists() && pageFile.isViewable
+
+ if (isValid) {
+ if (pageFile.isDirectory) {
+ val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: "Table of Contents"
+
+ return rawPage(title) {
+ breadCrumbs(parentPaths)
+ h1 { +title }
+ ul {
+ pageNode.subNodes.renderInto(this, pagePath.takeIf { it.isNotEmpty() }, ".raw")
+ }
+ }
+ }
+
+ if (pageFile.isFile) {
+ val pageTemplate = pageFile.readText()
+ val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
+ val pageHtml = TextParserState.parseText(pageMarkup, TextParserRawPageTag.asTags, Unit)
+
+ val pageToC = TableOfContentsBuilder()
+ TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC)
+
+ return rawPage(pageToC.toPageTitle(), pageToC.toOpenGraph()) {
+ breadCrumbs(parentPaths)
+ unsafe { raw(pageHtml) }
+ }
+ }
+ }
+
+ val title = pagePath.substringAfterLast('/').toFriendlyPageTitle()
+
+ return rawPage(title) {
+ breadCrumbs(parentPaths)
+ h1 { +title }
+ p {
+ +"This factbook does not exist. Would you like to "
+ a(href = "/lore.raw") { +"return to the table of contents" }
+ +"?"
+ }
+ }
+}
+
suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
val totalsData = processGuestbook()
val pagePathParts = parameters.getAll("path")!!
val pagePath = pagePathParts.joinToString("/")
+
+ if (pagePath.endsWith(".raw"))
+ return loreRawArticlePage(pagePath)
+
val pageFile = File(Configuration.CurrentConfiguration.articleDir).combineSafe(pagePath)
val pageNode = pageFile.toArticleNode()
}
}
}
+
+context(ApplicationCall)
+fun Quote.toRawHtml(title: String): HTML.() -> Unit {
+ return rawPage(title) {
+ a { id = "page-top" }
+ h1 { +title }
+ blockQuote {
+ +quote
+ }
+ p {
+ style = "align:right"
+ unsafe { raw("―") }
+ +Entities.nbsp
+ a(href = fullLink) { +author }
+ }
+ p {
+ style = "align:center"
+ a(href = fullLink) {
+ img(src = fullPortrait, alt = "Image of $author")
+ }
+ }
+ }
+}