From fdf4e18a783c15a9841d9cc761eafbf9a1ea4521 Mon Sep 17 00:00:00 2001 From: Sebastiano Poggi Date: Mon, 27 Jul 2026 17:14:23 +0200 Subject: [PATCH] [JEWEL-1345] Improve Markdown block renderer API Keep parseEmbeddedHtml=false on the HtmlBlock no-op path (do not wrap raw HTML in HtmlBlockWithAttributes/Paragraph), restore matching test helpers, and strengthen the renderer regression coverage. closes https://github.com/JetBrains/intellij-community/pull/3547 (cherry picked from commit 434b713d0ba2d1608f69cf90ce77a0c6b570ba51) (cherry picked from commit 059c7ff3bc277a53d571c0a2977f8732996d969d) IJ-MR-220560 GitOrigin-RevId: 5599220b7c3654143a1c4b66139832ec6c687dc6 --- .../markdown/core/api-dump-experimental.txt | 9 +++ .../core/metalava/core-api-0.41.0.txt | 5 ++ .../markdown/processing/MarkdownProcessor.kt | 21 ++++--- .../markdown/processing/ProcessingUtil.kt | 5 -- .../processing/html/HtmlElementConverter.kt | 8 +-- .../rendering/DefaultMarkdownBlockRenderer.kt | 55 ++++++++++++++----- .../rendering/MarkdownBlockRenderer.kt | 33 +++++++++++ .../ScrollSyncMarkdownBlockRenderer.kt | 1 + .../org/jetbrains/jewel/markdown/TestUtils.kt | 2 +- .../DefaultMarkdownBlockRendererTest.kt | 28 ++++++++++ 10 files changed, 133 insertions(+), 34 deletions(-) diff --git a/platform/jewel/markdown/core/api-dump-experimental.txt b/platform/jewel/markdown/core/api-dump-experimental.txt index 0650c2cb7857..cfb9a4c55e5f 100644 --- a/platform/jewel/markdown/core/api-dump-experimental.txt +++ b/platform/jewel/markdown/core/api-dump-experimental.txt @@ -386,6 +386,7 @@ f:org.jetbrains.jewel.markdown.processing.ProcessingUtilKt - RenderHeading(org.jetbrains.jewel.markdown.MarkdownBlock$Heading,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$Heading$HN,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - RenderHeading(org.jetbrains.jewel.markdown.MarkdownBlock$Heading,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$Heading,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - RenderHtmlBlock(org.jetbrains.jewel.markdown.MarkdownBlock$HtmlBlock,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$HtmlBlock,Z,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V +- RenderHtmlBlockWithAttributes(org.jetbrains.jewel.markdown.MarkdownBlock$HtmlBlockWithAttributes,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - RenderIndentedCodeBlock(org.jetbrains.jewel.markdown.MarkdownBlock$CodeBlock$IndentedCodeBlock,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$Code$Indented,Z,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - RenderList(org.jetbrains.jewel.markdown.MarkdownBlock$ListBlock,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$List,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - RenderListItem(org.jetbrains.jewel.markdown.MarkdownBlock$ListItem,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V @@ -399,6 +400,7 @@ f:org.jetbrains.jewel.markdown.processing.ProcessingUtilKt - getRendererExtensions():java.util.List - getRootStyling():org.jetbrains.jewel.markdown.rendering.MarkdownStyling - *:plus(org.jetbrains.jewel.markdown.extensions.MarkdownRendererExtension):org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer +- p:resolveImages(org.jetbrains.jewel.markdown.WithInlineMarkdown,androidx.compose.runtime.Composer,I):kotlin.Pair *f:org.jetbrains.jewel.markdown.rendering.DefaultMarkdownBlockRenderer$Companion *:org.jetbrains.jewel.markdown.rendering.ImageSourceResolver - *sf:Companion:org.jetbrains.jewel.markdown.rendering.ImageSourceResolver$Companion @@ -469,6 +471,10 @@ f:org.jetbrains.jewel.markdown.rendering.InlineMarkdownRendererKt - a:RenderHeading(org.jetbrains.jewel.markdown.MarkdownBlock$Heading,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$Heading$HN,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - a:RenderHeading(org.jetbrains.jewel.markdown.MarkdownBlock$Heading,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$Heading,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - a:RenderHtmlBlock(org.jetbrains.jewel.markdown.MarkdownBlock$HtmlBlock,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$HtmlBlock,Z,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V +- RenderHtmlBlockWithAttributes(org.jetbrains.jewel.markdown.MarkdownBlock$HtmlBlockWithAttributes,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V +- b:RenderHtmlBlockWithAttributes(org.jetbrains.jewel.markdown.MarkdownBlock$HtmlBlockWithAttributes,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I,I):V +- RenderImagesOnlyParagraph(java.util.Map,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V +- b:RenderImagesOnlyParagraph(java.util.Map,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I,I):V - a:RenderIndentedCodeBlock(org.jetbrains.jewel.markdown.MarkdownBlock$CodeBlock$IndentedCodeBlock,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$Code$Indented,Z,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - a:RenderList(org.jetbrains.jewel.markdown.MarkdownBlock$ListBlock,org.jetbrains.jewel.markdown.rendering.MarkdownStyling$List,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V - a:RenderListItem(org.jetbrains.jewel.markdown.MarkdownBlock$ListItem,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I):V @@ -484,6 +490,9 @@ f:org.jetbrains.jewel.markdown.rendering.InlineMarkdownRendererKt - a:getRootStyling():org.jetbrains.jewel.markdown.rendering.MarkdownStyling - *a:plus(org.jetbrains.jewel.markdown.extensions.MarkdownRendererExtension):org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer *f:org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer$Companion +*f:org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer$ComposeDefaultImpls +- sf:RenderHtmlBlockWithAttributes$default(org.jetbrains.jewel.markdown.MarkdownBlock$HtmlBlockWithAttributes,Z,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V +- sf:RenderImagesOnlyParagraph$default(java.util.Map,androidx.compose.ui.Modifier,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V *f:org.jetbrains.jewel.markdown.rendering.MarkdownStyling - sf:$stable:I - *sf:Companion:org.jetbrains.jewel.markdown.rendering.MarkdownStyling$Companion diff --git a/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt b/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt index 6821f95491b7..c7387a27e076 100644 --- a/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt +++ b/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt @@ -468,6 +468,7 @@ package org.jetbrains.jewel.markdown.rendering { method public java.util.List getRendererExtensions(); method public org.jetbrains.jewel.markdown.rendering.MarkdownStyling getRootStyling(); method @SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public operator org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer plus(org.jetbrains.jewel.markdown.extensions.MarkdownRendererExtension extension); + method @androidx.compose.runtime.Composable protected kotlin.Pair,java.util.Set> resolveImages(org.jetbrains.jewel.markdown.WithInlineMarkdown blockInlineContent); property public org.jetbrains.jewel.markdown.rendering.InlineMarkdownRenderer inlineRenderer; property public java.util.List rendererExtensions; property public org.jetbrains.jewel.markdown.rendering.MarkdownStyling rootStyling; @@ -475,6 +476,8 @@ package org.jetbrains.jewel.markdown.rendering { } public static final class DefaultMarkdownBlockRenderer.Companion { + method protected androidx.compose.runtime.ProvidableCompositionLocal getLocalTextAlignment(); + property protected androidx.compose.runtime.ProvidableCompositionLocal LocalTextAlignment; } @SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public interface ImageSourceResolver { @@ -570,6 +573,8 @@ package org.jetbrains.jewel.markdown.rendering { method @androidx.compose.runtime.Composable public void RenderHeading(org.jetbrains.jewel.markdown.MarkdownBlock.Heading block, org.jetbrains.jewel.markdown.rendering.MarkdownStyling.Heading styling, boolean enabled, kotlin.jvm.functions.Function1 onUrlClick, androidx.compose.ui.Modifier modifier); method @androidx.compose.runtime.Composable public void RenderHeading(org.jetbrains.jewel.markdown.MarkdownBlock.Heading block, org.jetbrains.jewel.markdown.rendering.MarkdownStyling.Heading.HN styling, boolean enabled, kotlin.jvm.functions.Function1 onUrlClick, androidx.compose.ui.Modifier modifier); method @androidx.compose.runtime.Composable public void RenderHtmlBlock(org.jetbrains.jewel.markdown.MarkdownBlock.HtmlBlock block, org.jetbrains.jewel.markdown.rendering.MarkdownStyling.HtmlBlock styling, boolean enabled, androidx.compose.ui.Modifier modifier); + method @androidx.compose.runtime.Composable public default void RenderHtmlBlockWithAttributes(org.jetbrains.jewel.markdown.MarkdownBlock.HtmlBlockWithAttributes block, boolean enabled, kotlin.jvm.functions.Function1 onUrlClick, optional androidx.compose.ui.Modifier modifier); + method @androidx.compose.runtime.Composable public default void RenderImagesOnlyParagraph(java.util.Map images, optional androidx.compose.ui.Modifier modifier); method @androidx.compose.runtime.Composable public void RenderIndentedCodeBlock(org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock.IndentedCodeBlock block, org.jetbrains.jewel.markdown.rendering.MarkdownStyling.Code.Indented styling, boolean enabled, androidx.compose.ui.Modifier modifier); method @androidx.compose.runtime.Composable public void RenderList(org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock block, org.jetbrains.jewel.markdown.rendering.MarkdownStyling.List styling, boolean enabled, kotlin.jvm.functions.Function1 onUrlClick, androidx.compose.ui.Modifier modifier); method @androidx.compose.runtime.Composable public void RenderListItem(org.jetbrains.jewel.markdown.MarkdownBlock.ListItem block, boolean enabled, kotlin.jvm.functions.Function1 onUrlClick, androidx.compose.ui.Modifier modifier); diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt index d180a8ecd704..c7576f767353 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt @@ -60,7 +60,8 @@ import org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer * provided by the [MarkdownParserFactory], but you can provide your own if you need to customize the parser — e.g., * to ignore certain tags. If [markdownMode] is `MarkdownMode.WithEditor`, make sure you set * `includeSourceSpans(IncludeSourceSpans.BLOCKS)` on the parser. - * @param parseEmbeddedHtml If `true`, a subset of native HTML elements will be parsed as Markdown blocks. + * @param parseEmbeddedHtml If `true`, a subset of native HTML elements will be parsed as Markdown blocks. See + * [MarkdownHtmlConverter]. */ @ApiStatus.Experimental @ExperimentalJewelApi @@ -71,7 +72,7 @@ public class MarkdownProcessor( MarkdownParserFactory.create(optimizeEdits = markdownMode is MarkdownMode.EditorPreview, extensions), private val parseEmbeddedHtml: Boolean = false, ) { - @Suppress("UnusedPrivateProperty") // languageRecognizer is only here for binary compat reasons + @Suppress("UnusedPrivateProperty", "DEPRECATION") // languageRecognizer is only here for binary compat reasons @Deprecated( "`languageRecognizer` is not necessary anymore. Use the constructor without it.", replaceWith = ReplaceWith("MarkdownProcessor(extensions, markdownMode, commonMarkParser)"), @@ -85,6 +86,7 @@ public class MarkdownProcessor( parseEmbeddedHtml: Boolean = false, ) : this(extensions, markdownMode, commonMarkParser, parseEmbeddedHtml) + @Suppress("DEPRECATION") @Deprecated("Use a version with a `parseEmbeddedHtml` parameter", level = DeprecationLevel.HIDDEN) public constructor( extensions: List = emptyList(), @@ -94,6 +96,7 @@ public class MarkdownProcessor( languageRecognizer: (String) -> MimeType? = { MimeType.Known.fromMarkdownLanguageName(it) }, ) : this(extensions, markdownMode, commonMarkParser, languageRecognizer, false) + @Suppress("DEPRECATION") @Deprecated("Use a version with a `parseEmbeddedHtml` parameter", level = DeprecationLevel.HIDDEN) public constructor( extensions: List = emptyList(), @@ -351,7 +354,7 @@ public class MarkdownProcessor( intermediateHtmlBlocks.mapNotNull { htmlElement -> htmlConverter.convert(this@MarkdownProcessor, htmlElement) { newMdBlock, lines -> when (newMdBlock) { - is MarkdownBlock.HtmlBlock -> return@convert newMdBlock + is MarkdownBlock.HtmlBlockWithAttributes -> return@convert newMdBlock is MarkdownBlock.ListItem -> return@convert newMdBlock // we convert the list item's inner block instead else -> { @@ -370,7 +373,7 @@ public class MarkdownProcessor( MarkdownBlock.BlockQuote(processChildren(this)) private fun Heading.toMarkdownHeadingOrNull(): MarkdownBlock.Heading? { - if (level < 1 || level > 6) return null + if (level !in 1..6) return null return MarkdownBlock.Heading(inlineContent = readInlineMarkdown(this@MarkdownProcessor), level = level) } @@ -380,6 +383,11 @@ public class MarkdownProcessor( private fun IndentedCodeBlock.toMarkdownCodeBlockOrNull(): CodeBlock.IndentedCodeBlock = CodeBlock.IndentedCodeBlock(literal.trimEnd('\n')) + private fun HtmlBlock.toMarkdownHtmlBlockOrNull(): MarkdownBlock.HtmlBlock? { + if (literal.isBlank()) return null + return MarkdownBlock.HtmlBlock(literal.trimEnd('\n')) + } + private fun BulletList.toMarkdownListOrNull(): ListBlock.UnorderedList? { val children = processListItems() if (children.isEmpty()) return null @@ -443,11 +451,6 @@ public class MarkdownProcessor( forEachChild { child -> child.traverseAll(action) } } - private fun HtmlBlock.toMarkdownHtmlBlockOrNull(): MarkdownBlock.HtmlBlock? { - if (literal.isBlank()) return null - return MarkdownBlock.HtmlBlock(literal.trimEnd('\n')) - } - /** Creates a copy of this [MarkdownProcessor] with the same properties, plus the provided [extension]. */ @ApiStatus.Experimental @ExperimentalJewelApi diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt index 0c1014902b46..e69c453a84bf 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/ProcessingUtil.kt @@ -17,7 +17,6 @@ import org.commonmark.node.Text as CMText import org.commonmark.parser.beta.ParsedInline import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.ExperimentalJewelApi -import org.jetbrains.jewel.foundation.util.JewelLogger import org.jetbrains.jewel.markdown.DimensionSize import org.jetbrains.jewel.markdown.InlineMarkdown import org.jetbrains.jewel.markdown.WithInlineMarkdown @@ -153,10 +152,6 @@ internal fun List.renderAsSimpleText(): String = buildString { is WithTextContent -> append(node.content) is InlineMarkdown.HardLineBreak -> append('\n') is InlineMarkdown.SoftLineBreak -> append(' ') - else -> { - JewelLogger.getInstance("MarkdownProcessingUtil") - .debug("Ignoring node ${node.javaClass.simpleName} for text rendering") - } } } } diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/HtmlElementConverter.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/HtmlElementConverter.kt index 6d3526503828..3c0adf89dd34 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/HtmlElementConverter.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/html/HtmlElementConverter.kt @@ -10,7 +10,7 @@ import org.jetbrains.jewel.markdown.processing.MarkdownProcessor import org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer /** - * Defines a mechanism for converting HTML elements their child elements, and inline elements into supported Markdown + * Defines a mechanism for converting HTML elements, their child elements, and inline elements into supported Markdown * elements (blocks or inlines). */ @ApiStatus.Experimental @@ -76,7 +76,7 @@ internal class MarkdownHtmlConverter { ) ?: return null val transformedBlock = transform(convertedBlock, htmlElement.lineRange) - return if (htmlElement.attributes.isEmpty()) { + if (htmlElement.attributes.isEmpty()) { transformedBlock } else { transform( @@ -92,11 +92,11 @@ internal class MarkdownHtmlConverter { processor: MarkdownProcessor, transform: (MarkdownBlock, IntRange) -> MarkdownBlock, ): List { - // Some HTML elements may make up a block (e.g. nested list), but some can be pure inlines. + // Some HTML elements may make up a block (e.g., nested list), but some can be pure inlines. // For example, `
  • Hello

  • ` is a block inside ListItem, but `
  • Hello
  • ` is not. // What's worse, `
  • Pay attention to this:
      ...
  • ` combines both inlines and blocks. // The idea here is to split the children into sublists of elements convertible to inline lists, - // separated by markdown blocks. + // separated by Markdown blocks. val blocks = mutableListOf() val currentInlineHtmlElements = mutableListOf() var currentLine = lineRange.first diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt index d8415bd025e3..8b4aabbf37a9 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt @@ -88,6 +88,7 @@ import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock.IndentedCodeBlock import org.jetbrains.jewel.markdown.MarkdownBlock.CustomBlock import org.jetbrains.jewel.markdown.MarkdownBlock.Heading import org.jetbrains.jewel.markdown.MarkdownBlock.HtmlBlock +import org.jetbrains.jewel.markdown.MarkdownBlock.HtmlBlockWithAttributes import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock.OrderedList import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock.UnorderedList @@ -126,6 +127,8 @@ public open class DefaultMarkdownBlockRenderer( override val rendererExtensions: List = emptyList(), override val inlineRenderer: InlineMarkdownRenderer = InlineMarkdownRenderer.create(rendererExtensions), ) : MarkdownBlockRenderer { + private val unsupportedBlockTypes = mutableSetOf() + @Composable override fun RenderBlocks( blocks: List, @@ -153,14 +156,12 @@ public open class DefaultMarkdownBlockRenderer( is ListItem -> RenderListItem(block, enabled, onUrlClick, modifier) is Paragraph -> RenderParagraph(block, rootStyling.paragraph, enabled, onUrlClick, modifier) ThematicBreak -> RenderThematicBreak(rootStyling.thematicBreak, enabled, modifier) - is MarkdownBlock.HtmlBlockWithAttributes -> - RenderHtmlBlockWithAttributes(block, enabled, onUrlClick, modifier) - + is HtmlBlockWithAttributes -> RenderHtmlBlockWithAttributes(block, enabled, onUrlClick, modifier) is CustomBlock -> { - rendererExtensions - .find { it.blockRenderer?.canRender(block) == true } - ?.blockRenderer - ?.RenderCustomBlock( + val blockRenderer = + rendererExtensions.find { it.blockRenderer?.canRender(block) == true }?.blockRenderer + if (blockRenderer != null) { + blockRenderer.RenderCustomBlock( block = block, blockRenderer = this, inlineRenderer = inlineRenderer, @@ -168,10 +169,22 @@ public open class DefaultMarkdownBlockRenderer( modifier = modifier, onUrlClick = onUrlClick, ) + } else { + logUnsupportedBlock(block) + } } } } + private fun logUnsupportedBlock(block: MarkdownBlock) { + // We do not render unsupported blocks; emit a one-off log per renderer instance (best-effort). + val simpleName = block::class.simpleName + if (simpleName != null && simpleName !in unsupportedBlockTypes) { + JewelLogger.getInstance(javaClass).warn("Cannot render unsupported block type: $simpleName.") + unsupportedBlockTypes += simpleName + } + } + @Composable override fun RenderParagraph( block: Paragraph, @@ -660,7 +673,7 @@ public open class DefaultMarkdownBlockRenderer( } @Composable - private fun resolveImages(blockInlineContent: WithInlineMarkdown): ResolvedImages { + protected open fun resolveImages(blockInlineContent: WithInlineMarkdown): ResolvedImages { val map = remember(blockInlineContent) { mutableStateMapOf() } val failedSources = remember(blockInlineContent) { mutableStateSetOf() } val imagesRenderer = rendererExtensions.firstNotNullOfOrNull { it.imageRendererExtension } @@ -705,8 +718,8 @@ public open class DefaultMarkdownBlockRenderer( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - // We use movableContent so changing the flag doesn't reset the content - val movableContent = remember { movableContentOf { content() } } + // We use movableContent, so changing the flag doesn't reset the content + val movableContent = remember(content) { movableContentOf { content() } } if (isScrollable) { HorizontallyScrollableContainer(modifier) { movableContent() } } else { @@ -715,12 +728,21 @@ public open class DefaultMarkdownBlockRenderer( } @Composable - private fun RenderHtmlBlockWithAttributes( - block: MarkdownBlock.HtmlBlockWithAttributes, + override fun RenderHtmlBlockWithAttributes( + block: HtmlBlockWithAttributes, enabled: Boolean, onUrlClick: (String) -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier, ) { + val mdBlock = block.mdBlock + if ( + block.attributes.isEmpty() && + mdBlock is Paragraph && + mdBlock.inlineContent.singleOrNull() is InlineMarkdown.HtmlInline + ) { + return + } + val textAlignment: TextAlign = when (block.attributes["align"]) { "left" -> TextAlign.Start @@ -1052,8 +1074,11 @@ public open class DefaultMarkdownBlockRenderer( DefaultMarkdownBlockRenderer(rootStyling, rendererExtensions = rendererExtensions + extension, inlineRenderer) public companion object { - @Suppress("VariableNaming") - private val LocalTextAlignment: ProvidableCompositionLocal = staticCompositionLocalOf { + /** + * Holds the current text alignment for the subtree of the composition. Used by the HTML parsing path to + * transmit text alignment down the tree. + */ + protected val LocalTextAlignment: ProvidableCompositionLocal = staticCompositionLocalOf { TextAlign.Start } } diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt index 0e2798166f3b..4b038afa4493 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer.kt @@ -1,5 +1,6 @@ package org.jetbrains.jewel.markdown.rendering +import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -315,6 +316,38 @@ public interface MarkdownBlockRenderer { modifier: Modifier, ) + /** + * Renders a [MarkdownBlock.HtmlBlockWithAttributes] into a Compose UI. Raw HTML blocks may be exposed as + * [MarkdownBlock.HtmlBlockWithAttributes] wrapping a paragraph with raw HTML inline content. Supported parsed HTML + * is converted into Markdown blocks and wrapped with attributes when applicable. + * + * @param block The HTML block to render. + * @param enabled True if the block should be enabled, false otherwise. + * @param onUrlClick The callback invoked when a link is clicked. + * @param modifier The modifier to be applied to the composable. + */ + @Composable + public fun RenderHtmlBlockWithAttributes( + block: MarkdownBlock.HtmlBlockWithAttributes, + enabled: Boolean, + onUrlClick: (String) -> Unit, + modifier: Modifier = Modifier, + ) { + // No-op by default + } + + /** + * Renders a paragraph containing only images into a Compose UI. This is delegated to from [RenderParagraph] when it + * only contains images, and uses a more efficient path than inline images in a text run. + * + * @param images The images to render. + * @param modifier The modifier to be applied to the composable. + */ + @Composable + public fun RenderImagesOnlyParagraph(images: Map, modifier: Modifier = Modifier) { + // No-op by default + } + /** * Creates a copy of this instance, using the provided non-null parameters, or the current values for the null ones. * diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt index 5501921a1d08..7477ec16ac6c 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt @@ -111,6 +111,7 @@ public open class ScrollSyncMarkdownBlockRenderer( } } + @Suppress("DEPRECATION") @Deprecated( message = "This class function is not scalable as it relies on a pre-resolved MimeType object. " + diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/TestUtils.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/TestUtils.kt index cf24cacd878a..1779b98b4316 100644 --- a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/TestUtils.kt +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/TestUtils.kt @@ -67,13 +67,13 @@ private fun MarkdownBlock.findDifferenceWith(expected: MarkdownBlock, indentSize return when (this) { is Paragraph -> diffParagraph(this, expected, indent) is BlockQuote -> children.findDifferences((expected as BlockQuote).children, indentSize) - is HtmlBlock -> diffHtmlBlock(this, expected, indent) is FencedCodeBlock -> diffFencedCodeBlock(this, expected, indent) is IndentedCodeBlock -> diffIndentedCodeBlock(this, expected, indent) is Heading -> diffHeading(this, expected, indent) is ListBlock -> diffList(this, expected, indentSize, indent) is ListItem -> children.findDifferences((expected as ListItem).children, indentSize) is ThematicBreak -> emptyList() // They can only differ in their node + is HtmlBlock -> diffHtmlBlock(this, expected, indent) is HtmlBlockWithAttributes -> diffHtmlBlockWithAttributes(this, expected, indent) else -> error("Unsupported MarkdownBlock: ${this.javaClass.name}") } diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRendererTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRendererTest.kt index f435caf229ee..cc830297f2e5 100644 --- a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRendererTest.kt +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRendererTest.kt @@ -8,10 +8,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.text.buildAnnotatedString +import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.processing.MarkdownProcessor import org.jetbrains.jewel.markdown.testing.MarkdownTestTheme import org.jetbrains.jewel.markdown.testing.createMarkdownTestStyling @@ -126,6 +129,31 @@ public class DefaultMarkdownBlockRendererTest { } } + @Test + public fun `raw HTML blocks are not rendered by default`() { + runComposeUiTest { + // parseEmbeddedHtml defaults to false: keep producing HtmlBlock (renderer no-op), + // not HtmlBlockWithAttributes wrapping a Paragraph that could leak raw HTML text. + val processor = MarkdownProcessor(parseEmbeddedHtml = false) + val blocks = processor.processMarkdownDocument("
    Raw HTML
    \n\nVisible paragraph") + + assertEquals(2, blocks.size) + assertTrue(blocks[0] is MarkdownBlock.HtmlBlock) + assertEquals("
    Raw HTML
    ", (blocks[0] as MarkdownBlock.HtmlBlock).content) + + setContent { + MarkdownTestTheme { + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(), emptyList()) + renderer.RenderBlocks(blocks, enabled = true, onUrlClick = {}, modifier = Modifier) + } + } + + onAllNodesWithText("
    Raw HTML
    ").assertCountEquals(0) + onAllNodesWithText("Raw HTML").assertCountEquals(0) + onNodeWithText("Visible paragraph").assertExists() + } + } + @Test public fun `appendInlineContent uses expected annotation tag`() { val annotated = buildAnnotatedString { appendInlineContent("testId") }