feat: add markdown renderer with CommonMark support

This commit is contained in:
2026-03-16 21:12:08 -04:00
parent 1bd540b1cd
commit 72e23fded8
4 changed files with 212 additions and 0 deletions

View File

@@ -54,6 +54,8 @@ dependencies {
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.swiperefreshlayout)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.commonmark)
implementation(libs.commonmark.ext.gfm.tables)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)

View File

@@ -0,0 +1,53 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import android.text.Spanned
import android.text.method.MovementMethod
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.core.text.HtmlCompat
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
class MarkdownRenderer(
private val parseToHtml: (String) -> String = defaultParseToHtml(),
private val htmlToSpanned: (String) -> Spanned = { html ->
HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
},
) {
fun render(markdown: String): Spanned {
return try {
val html = parseToHtml(markdown)
htmlToSpanned(html)
} catch (_: Exception) {
htmlToSpanned(markdown)
}
}
companion object {
fun enableLinks(textView: TextView) {
enableLinks(
applyMovementMethod = { movementMethod -> textView.movementMethod = movementMethod },
movementMethodProvider = { LinkMovementMethod.getInstance() },
)
}
fun enableLinks(
applyMovementMethod: (MovementMethod) -> Unit,
movementMethodProvider: () -> MovementMethod = { LinkMovementMethod.getInstance() },
) {
applyMovementMethod(movementMethodProvider())
}
private fun defaultParseToHtml(): (String) -> String {
val parser = Parser.builder().build()
val renderer = HtmlRenderer.builder()
.escapeHtml(true)
.build()
return { markdown ->
val document = parser.parse(markdown)
renderer.render(document)
}
}
}
}

View File

@@ -0,0 +1,154 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MarkdownRendererTest {
@Test
fun render_markdownBoldAndLink_returnsStyledSpanned() {
val renderer = MarkdownRenderer(
htmlToSpanned = { html ->
val spans = mutableListOf<TestSpan>()
if (html.contains("<strong>bold</strong>")) {
spans += TestSpan.Bold
}
if (html.contains("<a href=\"https://kan.bn\">Kan.bn</a>")) {
spans += TestSpan.Link("https://kan.bn")
}
TestSpanned(html, spans)
},
)
val result = renderer.render("This is **bold** and [Kan.bn](https://kan.bn)")
val boldSpans = result.getSpans(0, result.length, TestSpan.Bold::class.java)
val linkSpans = result.getSpans(0, result.length, TestSpan.Link::class.java)
assertTrue(boldSpans.isNotEmpty())
assertTrue(linkSpans.any { it.url == "https://kan.bn" })
}
@Test
fun render_whenRenderingFails_returnsPlainTextFallback() {
val renderer = MarkdownRenderer(
parseToHtml = { throw IllegalStateException("boom") },
htmlToSpanned = { html -> TestSpanned(html) },
)
val result = renderer.render("**keep this literal**")
assertEquals("**keep this literal**", result.toString())
}
@Test
fun render_rawHtml_isEscaped() {
val renderer = MarkdownRenderer(
htmlToSpanned = { html -> TestSpanned(html) },
)
val result = renderer.render("<script>alert(1)</script> **ok**")
assertTrue(result.toString().contains("&lt;script&gt;alert(1)&lt;/script&gt;"))
}
@Test
fun enableLinks_setsLinkMovementMethodOnPreviewTextWidget() {
var applied = false
val fakeMovementMethod = TestMovementMethod()
MarkdownRenderer.enableLinks(
applyMovementMethod = { movementMethod ->
applied = movementMethod === fakeMovementMethod
},
movementMethodProvider = { fakeMovementMethod },
)
assertTrue(applied)
}
private sealed interface TestSpan {
data object Bold : TestSpan
data class Link(val url: String) : TestSpan
}
private class TestMovementMethod : android.text.method.MovementMethod {
override fun initialize(widget: android.widget.TextView?, text: android.text.Spannable?) = Unit
override fun onKeyDown(
widget: android.widget.TextView?,
text: android.text.Spannable?,
keyCode: Int,
event: android.view.KeyEvent?,
): Boolean = false
override fun onKeyUp(
widget: android.widget.TextView?,
text: android.text.Spannable?,
keyCode: Int,
event: android.view.KeyEvent?,
): Boolean = false
override fun onKeyOther(
view: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.KeyEvent?,
): Boolean = false
override fun onTakeFocus(widget: android.widget.TextView?, text: android.text.Spannable?, direction: Int) = Unit
override fun onTrackballEvent(
widget: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.MotionEvent?,
): Boolean = false
override fun onTouchEvent(
widget: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.MotionEvent?,
): Boolean = false
override fun onGenericMotionEvent(
widget: android.widget.TextView?,
text: android.text.Spannable?,
event: android.view.MotionEvent?,
): Boolean = false
override fun canSelectArbitrarily(): Boolean = false
}
private class TestSpanned(
private val raw: String,
private val spans: List<Any> = emptyList(),
) : android.text.Spanned {
override val length: Int
get() = raw.length
override fun get(index: Int): Char = raw[index]
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = raw.subSequence(startIndex, endIndex)
override fun toString(): String = raw
override fun getSpanStart(tag: Any): Int = if (spans.contains(tag)) 0 else -1
override fun getSpanEnd(tag: Any): Int = if (spans.contains(tag)) raw.length else -1
override fun getSpanFlags(tag: Any): Int = 0
override fun nextSpanTransition(start: Int, limit: Int, kind: Class<*>?): Int = limit
override fun <T : Any> getSpans(start: Int, end: Int, kind: Class<T>): Array<T> {
val filtered = spans.filter { kind.isInstance(it) }.map { requireNotNull(kind.cast(it)) }
@Suppress("UNCHECKED_CAST")
val array = java.lang.reflect.Array.newInstance(kind, filtered.size) as Array<T>
filtered.forEachIndexed { index, element ->
array[index] = element
}
return array
}
}
}

View File

@@ -16,6 +16,7 @@ lifecycle = "2.8.7"
swiperefreshlayout = "1.1.0" swiperefreshlayout = "1.1.0"
recyclerview = "1.3.2" recyclerview = "1.3.2"
activity = "1.9.3" activity = "1.9.3"
commonmark = "0.22.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -35,6 +36,8 @@ androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", ve
androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" }
commonmark-ext-gfm-tables = { group = "org.commonmark", name = "commonmark-ext-gfm-tables", version.ref = "commonmark" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }