diff --git a/dokka-subprojects/plugin-base/api/plugin-base.api b/dokka-subprojects/plugin-base/api/plugin-base.api index 13f877e353..8d768b4222 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 ca86d4d52d..6fa4270b9a 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 by extensionPoint() public val htmlPreprocessors: ExtensionPoint 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 by extensionPoint() + @Deprecated("It is not used anymore") public val tabSortingStrategy: ExtensionPoint by extensionPoint() public val immediateHtmlCommandConsumer: ExtensionPoint 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 0000000000..29af6f980b --- /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 083876d5cc..e7b77383d9 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 = context.plugin().query { htmlPreprocessors } + private val customCodeBlockRenderers = context.plugin().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 0000000000..c30463f97e --- /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, + 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().htmlCodeBlockRenderers with renderer + } + + @OptIn(DokkaPluginApiPreview::class) + override fun pluginApiPreviewAcknowledgement(): PluginApiPreviewAcknowledgement = + PluginApiPreviewAcknowledgement + } + + class MultiRendererPlugin( + renderer1: HtmlCodeBlockRenderer, + renderer2: HtmlCodeBlockRenderer + ) : DokkaPlugin() { + val codeBlockRenderer1 by extending { + plugin().htmlCodeBlockRenderers with renderer1 + } + val codeBlockRenderer2 by extending { + plugin().htmlCodeBlockRenderers with renderer2 + } + + @OptIn(DokkaPluginApiPreview::class) + override fun pluginApiPreviewAcknowledgement(): PluginApiPreviewAcknowledgement = + PluginApiPreviewAcknowledgement + } +}