aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOleg Yukhnevich <whyoleg@gmail.com>2023-11-16 16:58:19 +0200
committerGitHub <noreply@github.com>2023-11-16 16:58:19 +0200
commitf333e425440701e50361f61acc2f9cb2d10fac1a (patch)
tree85fded28ef08dd658bb53f16af2976b89a3b1aa7
parent1e126789a92e512b0a426044220043632e7dbf1b (diff)
downloaddokka-f333e425440701e50361f61acc2f9cb2d10fac1a.tar.gz
dokka-f333e425440701e50361f61acc2f9cb2d10fac1a.tar.bz2
dokka-f333e425440701e50361f61acc2f9cb2d10fac1a.zip
Implement custom code block renderers support (#3320)
* multiple custom renderers can be installed to support different languages independently * only language and code properties are provided for extension
-rw-r--r--dokka-subprojects/plugin-base/api/plugin-base.api7
-rw-r--r--dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/DokkaBase.kt8
-rw-r--r--dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlCodeBlockRenderer.kt49
-rw-r--r--dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlRenderer.kt26
-rw-r--r--dokka-subprojects/plugin-base/src/test/kotlin/renderers/html/CodeBlocksTest.kt336
5 files changed, 426 insertions, 0 deletions
diff --git a/dokka-subprojects/plugin-base/api/plugin-base.api b/dokka-subprojects/plugin-base/api/plugin-base.api
index 13f877e3..8d768b42 100644
--- a/dokka-subprojects/plugin-base/api/plugin-base.api
+++ b/dokka-subprojects/plugin-base/api/plugin-base.api
@@ -60,6 +60,7 @@ public final class org/jetbrains/dokka/base/DokkaBase : org/jetbrains/dokka/plug
public final fun getExternalLocationProviderFactory ()Lorg/jetbrains/dokka/plugability/ExtensionPoint;
public final fun getFallbackMerger ()Lorg/jetbrains/dokka/plugability/Extension;
public final fun getFileWriter ()Lorg/jetbrains/dokka/plugability/Extension;
+ public final fun getHtmlCodeBlockRenderers ()Lorg/jetbrains/dokka/plugability/ExtensionPoint;
public final fun getHtmlPreprocessors ()Lorg/jetbrains/dokka/plugability/ExtensionPoint;
public final fun getHtmlRenderer ()Lorg/jetbrains/dokka/plugability/Extension;
public final fun getImmediateHtmlCommandConsumer ()Lorg/jetbrains/dokka/plugability/ExtensionPoint;
@@ -297,6 +298,12 @@ public final class org/jetbrains/dokka/base/renderers/html/CustomResourceInstall
public fun invoke (Lorg/jetbrains/dokka/pages/RootPageNode;)Lorg/jetbrains/dokka/pages/RootPageNode;
}
+public abstract interface class org/jetbrains/dokka/base/renderers/html/HtmlCodeBlockRenderer {
+ public abstract fun buildCodeBlock (Lkotlinx/html/FlowContent;Ljava/lang/String;Ljava/lang/String;)V
+ public abstract fun isApplicableForDefinedLanguage (Ljava/lang/String;)Z
+ public abstract fun isApplicableForUndefinedLanguage (Ljava/lang/String;)Z
+}
+
public final class org/jetbrains/dokka/base/renderers/html/HtmlFormatingUtilsKt {
public static final fun buildBreakableDotSeparatedHtml (Lkotlinx/html/FlowContent;Ljava/lang/String;)V
public static final fun buildBreakableText (Lkotlinx/html/FlowContent;Ljava/lang/String;)V
diff --git a/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/DokkaBase.kt b/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/DokkaBase.kt
index ca86d4d5..6fa4270b 100644
--- a/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/DokkaBase.kt
+++ b/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/DokkaBase.kt
@@ -49,6 +49,14 @@ public class DokkaBase : DokkaPlugin() {
public val outputWriter: ExtensionPoint<OutputWriter> by extensionPoint()
public val htmlPreprocessors: ExtensionPoint<PageTransformer> by extensionPoint()
+ /**
+ * Extension point for providing custom HTML code block renderers.
+ *
+ * This extension point allows overriding the rendering of code blocks in different programming languages.
+ * Multiple renderers can be installed to support different languages independently.
+ */
+ public val htmlCodeBlockRenderers: ExtensionPoint<HtmlCodeBlockRenderer> by extensionPoint()
+
@Deprecated("It is not used anymore")
public val tabSortingStrategy: ExtensionPoint<TabSortingStrategy> by extensionPoint()
public val immediateHtmlCommandConsumer: ExtensionPoint<ImmediateHtmlCommandConsumer> by extensionPoint()
diff --git a/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlCodeBlockRenderer.kt b/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlCodeBlockRenderer.kt
new file mode 100644
index 00000000..29af6f98
--- /dev/null
+++ b/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlCodeBlockRenderer.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package org.jetbrains.dokka.base.renderers.html
+
+import kotlinx.html.FlowContent
+
+/**
+ * Provides an ability to override code blocks rendering differently dependent on the code language.
+ *
+ * Multiple renderers can be installed to support different languages in an independent way.
+ */
+public interface HtmlCodeBlockRenderer {
+
+ /**
+ * Whether this renderer supports rendering Markdown code blocks
+ * for the given [language] explicitly specified in the fenced code block definition,
+ */
+ public fun isApplicableForDefinedLanguage(language: String): Boolean
+
+ /**
+ * Whether this renderer supports rendering Markdown code blocks
+ * for the given [code] when language is not specified in fenced code blocks
+ * or indented code blocks are used.
+ */
+ public fun isApplicableForUndefinedLanguage(code: String): Boolean
+
+ /**
+ * Defines how to render [code] for specified [language] via HTML tags.
+ *
+ * The value of the [language] will be the same as in the input Markdown fenced code block definition.
+ * In the following example [language] = `kotlin` and [code] = `val a`:
+ * ~~~markdown
+ * ```kotlin
+ * val a
+ * ```
+ * ~~~
+ * The value of the [language] will be `null` if language is not specified in the fenced code block definition
+ * or indented code blocks are used.
+ * In the following example [language] = `null` and [code] = `val a`:
+ * ~~~markdown
+ * ```
+ * val a
+ * ```
+ * ~~~
+ */
+ public fun FlowContent.buildCodeBlock(language: String?, code: String)
+}
diff --git a/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlRenderer.kt b/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlRenderer.kt
index 083876d5..e7b77383 100644
--- a/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlRenderer.kt
+++ b/dokka-subprojects/plugin-base/src/main/kotlin/org/jetbrains/dokka/base/renderers/html/HtmlRenderer.kt
@@ -52,6 +52,7 @@ public open class HtmlRenderer(
private var shouldRenderSourceSetTabs: Boolean = false
override val preprocessors: List<PageTransformer> = context.plugin<DokkaBase>().query { htmlPreprocessors }
+ private val customCodeBlockRenderers = context.plugin<DokkaBase>().query { htmlCodeBlockRenderers }
/**
* Tabs themselves are created in HTML plugin since, currently, only HTML format supports them.
@@ -816,6 +817,31 @@ public open class HtmlRenderer(
code: ContentCodeBlock,
pageContext: ContentPage
) {
+ if (customCodeBlockRenderers.isNotEmpty()) {
+ val language = code.language.takeIf(String::isNotBlank)
+ val codeText = buildString {
+ code.children.forEach {
+ when (it) {
+ is ContentText -> append(it.text)
+ is ContentBreakLine -> appendLine()
+ }
+ }
+ }
+
+ // we use first applicable renderer to override rendering
+ val applicableRenderer = when (language) {
+ null -> customCodeBlockRenderers.firstOrNull { it.isApplicableForUndefinedLanguage(codeText) }
+ else -> customCodeBlockRenderers.firstOrNull { it.isApplicableForDefinedLanguage(language) }
+ }
+ if (applicableRenderer != null) {
+ return with(applicableRenderer) {
+ buildCodeBlock(language, codeText)
+ }
+ }
+ }
+
+ // if there are no applicable custom renderers - fall back to default
+
div("sample-container") {
val codeLang = "lang-" + code.language.ifEmpty { "kotlin" }
val stylesWithBlock = code.style + TextStyle.Block + codeLang
diff --git a/dokka-subprojects/plugin-base/src/test/kotlin/renderers/html/CodeBlocksTest.kt b/dokka-subprojects/plugin-base/src/test/kotlin/renderers/html/CodeBlocksTest.kt
new file mode 100644
index 00000000..c30463f9
--- /dev/null
+++ b/dokka-subprojects/plugin-base/src/test/kotlin/renderers/html/CodeBlocksTest.kt
@@ -0,0 +1,336 @@
+/*
+ * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package renderers.html
+
+import kotlinx.html.FlowContent
+import kotlinx.html.div
+import org.jetbrains.dokka.base.DokkaBase
+import org.jetbrains.dokka.base.renderers.html.HtmlCodeBlockRenderer
+import org.jetbrains.dokka.base.testApi.testRunner.BaseAbstractTest
+import org.jetbrains.dokka.plugability.DokkaPlugin
+import org.jetbrains.dokka.plugability.DokkaPluginApiPreview
+import org.jetbrains.dokka.plugability.PluginApiPreviewAcknowledgement
+import org.jsoup.nodes.Element
+import signatures.renderedContent
+import utils.TestOutputWriter
+import utils.TestOutputWriterPlugin
+import utils.assertContains
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class CodeBlocksTest : BaseAbstractTest() {
+
+ private val configuration = dokkaConfiguration {
+ sourceSets {
+ sourceSet {
+ sourceRoots = listOf("src/")
+ }
+ }
+ }
+
+ private val contentWithExplicitLanguages =
+ """
+ /src/test.kt
+ package test
+
+ /**
+ * Hello, world!
+ *
+ * ```kotlin
+ * test("hello kotlin")
+ * ```
+ *
+ * ```custom
+ * test("hello custom")
+ * ```
+ *
+ * ```other
+ * test("hello other")
+ * ```
+ */
+ fun test(string: String) {}
+ """.trimIndent()
+
+ @Test
+ fun `default code block rendering`() = testCode(
+ contentWithExplicitLanguages,
+ emptyList()
+ ) {
+ val content = renderedContent("root/test/test.html")
+
+ // by default, every code block is rendered as an element with `lang-XXX` class,
+ // where XXX=language of code block
+ assertEquals("""test("hello kotlin")""", content.textOfSingleElementByClass("lang-kotlin"))
+ assertEquals("""test("hello custom")""", content.textOfSingleElementByClass("lang-custom"))
+ assertEquals("""test("hello other")""", content.textOfSingleElementByClass("lang-other"))
+ }
+
+ @Test
+ fun `code block rendering with custom renderer`() = testCode(
+ contentWithExplicitLanguages,
+ listOf(SingleRendererPlugin(CustomDefinedHtmlBlockRenderer))
+ ) {
+ val content = renderedContent("root/test/test.html")
+
+ assertEquals("""test("hello kotlin")""", content.textOfSingleElementByClass("lang-kotlin"))
+ assertEquals("""test("hello custom")""", content.textOfSingleElementByClass("custom-defined-language-block"))
+ assertEquals("""test("hello other")""", content.textOfSingleElementByClass("lang-other"))
+ }
+
+ @Test
+ fun `code block rendering with multiple custom renderers`() = testCode(
+ contentWithExplicitLanguages,
+ listOf(MultiRendererPlugin(CustomDefinedHtmlBlockRenderer, OtherDefinedHtmlBlockRenderer))
+ ) {
+ val content = renderedContent("root/test/test.html")
+
+ assertEquals("""test("hello kotlin")""", content.textOfSingleElementByClass("lang-kotlin"))
+ assertEquals("""test("hello custom")""", content.textOfSingleElementByClass("custom-defined-language-block"))
+ assertEquals("""test("hello other")""", content.textOfSingleElementByClass("other-defined-language-block"))
+ }
+
+ private val contentWithImplicitLanguages =
+ """
+ /src/test.kt
+ package test
+
+ /**
+ * Hello, world!
+ *
+ * ```
+ * test("hello kotlin")
+ * ```
+ *
+ * ```
+ * test("hello custom")
+ * ```
+ *
+ * ```
+ * test("hello other")
+ * ```
+ */
+ fun test(string: String) {}
+ """.trimIndent()
+
+ @Test
+ fun `default code block rendering with undefined language`() = testCode(
+ contentWithImplicitLanguages,
+ emptyList()
+ ) {
+ val content = renderedContent("root/test/test.html")
+
+ val contentsDefault = content.getElementsByClass("lang-kotlin").map(Element::wholeText)
+
+ assertContains(contentsDefault, """test("hello kotlin")""")
+ assertContains(contentsDefault, """test("hello custom")""")
+ assertContains(contentsDefault, """test("hello other")""")
+
+ assertEquals(3, contentsDefault.size)
+ }
+
+ @Test
+ fun `code block rendering with custom renderer and undefined language`() = testCode(
+ contentWithImplicitLanguages,
+ listOf(SingleRendererPlugin(CustomUndefinedHtmlBlockRenderer))
+ ) {
+ val content = renderedContent("root/test/test.html")
+
+ val contentsDefault = content.getElementsByClass("lang-kotlin").map(Element::wholeText)
+
+ assertContains(contentsDefault, """test("hello kotlin")""")
+ assertContains(contentsDefault, """test("hello other")""")
+
+ assertEquals(2, contentsDefault.size)
+
+ assertEquals("""test("hello custom")""", content.textOfSingleElementByClass("custom-undefined-language-block"))
+ }
+
+ @Test
+ fun `code block rendering with multiple custom renderers and undefined language`() = testCode(
+ contentWithImplicitLanguages,
+ listOf(MultiRendererPlugin(CustomUndefinedHtmlBlockRenderer, OtherUndefinedHtmlBlockRenderer))
+ ) {
+ val content = renderedContent("root/test/test.html")
+
+ assertEquals("""test("hello kotlin")""", content.textOfSingleElementByClass("lang-kotlin"))
+ assertEquals("""test("hello custom")""", content.textOfSingleElementByClass("custom-undefined-language-block"))
+ assertEquals("""test("hello other")""", content.textOfSingleElementByClass("other-undefined-language-block"))
+ }
+
+ @Test
+ fun `code block rendering with multiple mixed custom renderers`() = testCode(
+ """
+ /src/test.kt
+ package test
+
+ /**
+ * Hello, world!
+ *
+ * ```kotlin
+ * test("hello kotlin")
+ * ```
+ *
+ * ```
+ * test("hello custom")
+ * ```
+ *
+ * ```other
+ * test("hello other")
+ * ```
+ */
+ fun test(string: String) {}
+ """.trimIndent(),
+ listOf(
+ MultiRendererPlugin(
+ CustomUndefinedHtmlBlockRenderer,
+ OtherDefinedHtmlBlockRenderer,
+ )
+ )
+ ) {
+ val content = renderedContent("root/test/test.html")
+
+ assertEquals("""test("hello kotlin")""", content.textOfSingleElementByClass("lang-kotlin"))
+ assertEquals("""test("hello custom")""", content.textOfSingleElementByClass("custom-undefined-language-block"))
+ assertEquals("""test("hello other")""", content.textOfSingleElementByClass("other-defined-language-block"))
+ }
+
+ @Test
+ fun `multiline code block rendering with linebreaks`() = testCode(
+ """
+ /src/test.kt
+ package test
+
+ /**
+ * Hello, world!
+ *
+ * ```kotlin
+ * // something before linebreak
+ *
+ * test("hello kotlin")
+ * ```
+ *
+ * ```custom
+ * // something before linebreak
+ *
+ * test("hello custom")
+ * ```
+ */
+ fun test(string: String) {}
+ """.trimIndent(),
+ listOf(SingleRendererPlugin(CustomDefinedHtmlBlockRenderer))
+ ) {
+ val content = renderedContent("root/test/test.html")
+ assertEquals(
+ """
+ // something before linebreak
+
+ test("hello kotlin")
+ """.trimIndent(),
+ content.textOfSingleElementByClass("lang-kotlin")
+ )
+ assertEquals(
+ """
+ // something before linebreak
+
+ test("hello custom")
+ """.trimIndent(),
+ content.textOfSingleElementByClass("custom-defined-language-block")
+ )
+ }
+
+ private fun testCode(
+ source: String,
+ pluginOverrides: List<DokkaPlugin>,
+ block: TestOutputWriter.() -> Unit
+ ) {
+ val writerPlugin = TestOutputWriterPlugin()
+ testInline(source, configuration, pluginOverrides = pluginOverrides + listOf(writerPlugin)) {
+ renderingStage = { _, _ ->
+ writerPlugin.writer.block()
+ }
+ }
+ }
+
+ private fun Element.textOfSingleElementByClass(className: String): String {
+ val elements = getElementsByClass(className)
+ assertEquals(1, elements.size)
+ return elements.single().wholeText()
+ }
+
+ private object CustomDefinedHtmlBlockRenderer : HtmlCodeBlockRenderer {
+ override fun isApplicableForDefinedLanguage(language: String): Boolean = language == "custom"
+ override fun isApplicableForUndefinedLanguage(code: String): Boolean = false
+
+ override fun FlowContent.buildCodeBlock(language: String?, code: String) {
+ assertEquals("custom", language)
+ div("custom-defined-language-block") {
+ text(code)
+ }
+ }
+ }
+
+ private object OtherDefinedHtmlBlockRenderer : HtmlCodeBlockRenderer {
+ override fun isApplicableForDefinedLanguage(language: String): Boolean = language == "other"
+ override fun isApplicableForUndefinedLanguage(code: String): Boolean = false
+
+ override fun FlowContent.buildCodeBlock(language: String?, code: String) {
+ assertEquals("other", language)
+ div("other-defined-language-block") {
+ text(code)
+ }
+ }
+ }
+
+ private object CustomUndefinedHtmlBlockRenderer : HtmlCodeBlockRenderer {
+ override fun isApplicableForDefinedLanguage(language: String): Boolean = false
+ override fun isApplicableForUndefinedLanguage(code: String): Boolean = code.contains("custom")
+
+ override fun FlowContent.buildCodeBlock(language: String?, code: String) {
+ assertNull(language)
+ div("custom-undefined-language-block") {
+ text(code)
+ }
+ }
+ }
+
+ private object OtherUndefinedHtmlBlockRenderer : HtmlCodeBlockRenderer {
+ override fun isApplicableForDefinedLanguage(language: String): Boolean = false
+ override fun isApplicableForUndefinedLanguage(code: String): Boolean = code.contains("other")
+
+ override fun FlowContent.buildCodeBlock(language: String?, code: String) {
+ assertNull(language)
+ div("other-undefined-language-block") {
+ text(code)
+ }
+ }
+ }
+
+ class SingleRendererPlugin(renderer: HtmlCodeBlockRenderer) : DokkaPlugin() {
+ val codeBlockRenderer by extending {
+ plugin<DokkaBase>().htmlCodeBlockRenderers with renderer
+ }
+
+ @OptIn(DokkaPluginApiPreview::class)
+ override fun pluginApiPreviewAcknowledgement(): PluginApiPreviewAcknowledgement =
+ PluginApiPreviewAcknowledgement
+ }
+
+ class MultiRendererPlugin(
+ renderer1: HtmlCodeBlockRenderer,
+ renderer2: HtmlCodeBlockRenderer
+ ) : DokkaPlugin() {
+ val codeBlockRenderer1 by extending {
+ plugin<DokkaBase>().htmlCodeBlockRenderers with renderer1
+ }
+ val codeBlockRenderer2 by extending {
+ plugin<DokkaBase>().htmlCodeBlockRenderers with renderer2
+ }
+
+ @OptIn(DokkaPluginApiPreview::class)
+ override fun pluginApiPreviewAcknowledgement(): PluginApiPreviewAcknowledgement =
+ PluginApiPreviewAcknowledgement
+ }
+}