Build: enable ktlint+detekt, format sources, add core unit tests
This commit is contained in:
@@ -8,9 +8,7 @@ import org.springframework.context.annotation.Bean
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(MarkdownProperties::class)
|
||||
class LmsMarkdownAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer =
|
||||
MarkdownRenderer(allowHtml = properties.allowHtml)
|
||||
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = MarkdownRenderer(allowHtml = properties.allowHtml)
|
||||
}
|
||||
|
||||
@@ -32,20 +32,21 @@ import org.owasp.html.PolicyFactory
|
||||
class MarkdownRenderer(
|
||||
private val allowHtml: Boolean = false,
|
||||
) {
|
||||
private val options = MutableDataSet().apply {
|
||||
set(
|
||||
Parser.EXTENSIONS,
|
||||
listOf(
|
||||
TablesExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TaskListExtension.create(),
|
||||
FootnoteExtension.create(),
|
||||
AutolinkExtension.create(),
|
||||
),
|
||||
)
|
||||
set(HtmlRenderer.GENERATE_HEADER_ID, true)
|
||||
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
|
||||
}
|
||||
private val options =
|
||||
MutableDataSet().apply {
|
||||
set(
|
||||
Parser.EXTENSIONS,
|
||||
listOf(
|
||||
TablesExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TaskListExtension.create(),
|
||||
FootnoteExtension.create(),
|
||||
AutolinkExtension.create(),
|
||||
),
|
||||
)
|
||||
set(HtmlRenderer.GENERATE_HEADER_ID, true)
|
||||
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
|
||||
}
|
||||
|
||||
private val parser: Parser = Parser.builder(options).build()
|
||||
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
|
||||
@@ -55,21 +56,26 @@ class MarkdownRenderer(
|
||||
|
||||
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
|
||||
private fun wrapTables(html: String): String =
|
||||
html.replace("<table", "<div class=\"table-scroll\"><table")
|
||||
html
|
||||
.replace("<table", "<div class=\"table-scroll\"><table")
|
||||
.replace("</table>", "</table></div>")
|
||||
|
||||
private fun renderRaw(markdown: String, depth: Int): String {
|
||||
private fun renderRaw(
|
||||
markdown: String,
|
||||
depth: Int,
|
||||
): String {
|
||||
val out = StringBuilder()
|
||||
var cursor = 0
|
||||
for (match in SPOILER.findAll(markdown)) {
|
||||
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
|
||||
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
|
||||
val body = match.groupValues[2]
|
||||
val inner = if (depth < MAX_DEPTH) {
|
||||
renderRaw(body, depth + 1)
|
||||
} else {
|
||||
renderWithDirectives(body)
|
||||
}
|
||||
val inner =
|
||||
if (depth < MAX_DEPTH) {
|
||||
renderRaw(body, depth + 1)
|
||||
} else {
|
||||
renderWithDirectives(body)
|
||||
}
|
||||
out.append("<details class=\"spoiler\"><summary>")
|
||||
out.append(escapeHtml(title))
|
||||
out.append("</summary><div class=\"spoiler-body\">")
|
||||
@@ -81,8 +87,7 @@ class MarkdownRenderer(
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun renderSegment(markdown: String): String =
|
||||
renderer.render(parser.parse(markdown))
|
||||
private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown))
|
||||
|
||||
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
|
||||
private fun renderWithDirectives(markdown: String): String {
|
||||
@@ -118,51 +123,104 @@ class MarkdownRenderer(
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun renderInline(text: String): String =
|
||||
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
|
||||
private fun renderInline(text: String): String = renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
|
||||
|
||||
private fun escapeHtml(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
private fun escapeHtml(value: String): String =
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
|
||||
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder()
|
||||
.allowElements(
|
||||
"p", "br", "hr",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"strong", "b", "em", "i", "del", "s", "blockquote",
|
||||
"ul", "ol", "li",
|
||||
"a", "img",
|
||||
"code", "pre",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
|
||||
"video", "source", "track", "audio",
|
||||
)
|
||||
.allowAttributes("href").onElements("a")
|
||||
.allowAttributes("title").onElements("a", "img")
|
||||
.allowAttributes("src", "alt").onElements("img")
|
||||
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
|
||||
.onElements("video")
|
||||
.allowAttributes("src", "type").onElements("source")
|
||||
.allowAttributes("src", "kind", "label", "srclang", "default").onElements("track")
|
||||
.allowAttributes("class").onElements(
|
||||
"code", "pre", "div", "span", "details", "summary", "table", "th", "td",
|
||||
"figure", "figcaption", "video", "img",
|
||||
)
|
||||
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
.allowAttributes("colspan", "rowspan").onElements("td", "th")
|
||||
.allowUrlProtocols("http", "https", "mailto")
|
||||
.requireRelNofollowOnLinks()
|
||||
.toFactory()
|
||||
private fun buildSanitizer(): PolicyFactory =
|
||||
HtmlPolicyBuilder()
|
||||
.allowElements(
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"del",
|
||||
"s",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"a",
|
||||
"img",
|
||||
"code",
|
||||
"pre",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"details",
|
||||
"summary",
|
||||
"div",
|
||||
"span",
|
||||
"sup",
|
||||
"sub",
|
||||
"kbd",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"video",
|
||||
"source",
|
||||
"track",
|
||||
"audio",
|
||||
).allowAttributes("href")
|
||||
.onElements("a")
|
||||
.allowAttributes("title")
|
||||
.onElements("a", "img")
|
||||
.allowAttributes("src", "alt")
|
||||
.onElements("img")
|
||||
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
|
||||
.onElements("video")
|
||||
.allowAttributes("src", "type")
|
||||
.onElements("source")
|
||||
.allowAttributes("src", "kind", "label", "srclang", "default")
|
||||
.onElements("track")
|
||||
.allowAttributes("class")
|
||||
.onElements(
|
||||
"code",
|
||||
"pre",
|
||||
"div",
|
||||
"span",
|
||||
"details",
|
||||
"summary",
|
||||
"table",
|
||||
"th",
|
||||
"td",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"video",
|
||||
"img",
|
||||
).allowAttributes("id")
|
||||
.onElements("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
.allowAttributes("colspan", "rowspan")
|
||||
.onElements("td", "th")
|
||||
.allowUrlProtocols("http", "https", "mailto")
|
||||
.requireRelNofollowOnLinks()
|
||||
.toFactory()
|
||||
|
||||
private companion object {
|
||||
const val MAX_DEPTH = 5
|
||||
val MEDIA = Regex(
|
||||
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
|
||||
)
|
||||
val SPOILER = Regex(
|
||||
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
|
||||
)
|
||||
val MEDIA =
|
||||
Regex(
|
||||
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
|
||||
)
|
||||
val SPOILER =
|
||||
Regex(
|
||||
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package ru.ysu.lms.markdown
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MarkdownRendererTest {
|
||||
private val renderer = MarkdownRenderer()
|
||||
|
||||
@Test
|
||||
fun `renders basic markdown`() {
|
||||
val html = renderer.render("# Заголовок\n\n**жирный** и *курсив*")
|
||||
|
||||
assertThat(html).contains("<h1")
|
||||
assertThat(html).contains("Заголовок")
|
||||
assertThat(html).contains("<strong>жирный</strong>")
|
||||
assertThat(html).contains("<em>курсив</em>")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renders lists and code blocks`() {
|
||||
val html = renderer.render("- один\n- два\n\n```c\nint x = 1;\n```")
|
||||
|
||||
assertThat(html).contains("<ul>")
|
||||
assertThat(html).contains("<li>")
|
||||
assertThat(html).contains("один")
|
||||
assertThat(html).contains("<pre>")
|
||||
assertThat(html).contains("language-c")
|
||||
assertThat(html).contains("int x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wraps tables into scrollable container`() {
|
||||
val md =
|
||||
"""
|
||||
| № | Задание |
|
||||
|---|---|
|
||||
| 1 | Сумма |
|
||||
""".trimIndent()
|
||||
|
||||
val html = renderer.render(md)
|
||||
|
||||
assertThat(html).contains("<div class=\"table-scroll\"><table")
|
||||
assertThat(html).contains("</table></div>")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `escapes raw html and strips scripts`() {
|
||||
val html = renderer.render("<script>alert(1)</script>")
|
||||
|
||||
assertThat(html).doesNotContain("<script>")
|
||||
assertThat(html).contains("<script>")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renders spoiler container`() {
|
||||
val html = renderer.render(":::spoiler Ответ\nскрытый текст\n:::")
|
||||
|
||||
assertThat(html).contains("spoiler")
|
||||
assertThat(html).contains("скрытый текст")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user