feat: add markdown renderer with CommonMark support
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user