feat: add markdown renderer with CommonMark support
This commit is contained in:
@@ -54,6 +54,8 @@ dependencies {
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.commonmark.ext.gfm.tables)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("<script>alert(1)</script>"))
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ lifecycle = "2.8.7"
|
||||
swiperefreshlayout = "1.1.0"
|
||||
recyclerview = "1.3.2"
|
||||
activity = "1.9.3"
|
||||
commonmark = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
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-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user