IJAI-759 read Markdown fences from the rendering parser

(cherry picked from commit 128ceb30404006619a6a647e43c74658d6a4fd65)

GitOrigin-RevId: 84286f37a0c0d1f8a34bc37ab437fc619ef22cd8
This commit is contained in:
Evgenii Zakharchenko
2026-08-18 15:23:53 +00:00
committed by intellij-monorepo-bot
parent 1def929168
commit 84ee87e36c
2 changed files with 3 additions and 148 deletions
@@ -3,11 +3,9 @@ package com.intellij.markdown.utils
import com.intellij.openapi.util.NlsSafe
import org.intellij.markdown.IElementType
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.flavours.MarkdownFlavourDescriptor
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.CancellationToken
import org.intellij.markdown.parser.LinkMap
import org.intellij.markdown.parser.MarkdownParser
import org.jetbrains.annotations.ApiStatus
@@ -19,10 +17,7 @@ class MarkdownToHtmlConverter(
) {
@NlsSafe
fun convertMarkdownToHtml(@NlsSafe markdownText: String, server: String? = null): String {
// Typed as a CharSequence to reach the parser's supported overloads; the `String` ones are deprecated.
val text: CharSequence = markdownText
val parsedTree = MarkdownParser(flavourDescriptor, cancellationToken = CancellationToken.NonCancellable)
.buildMarkdownTreeFromString(text)
val parsedTree = MarkdownParser(flavourDescriptor).buildMarkdownTreeFromString(markdownText)
val providers = flavourDescriptor.createHtmlGeneratingProviders(
linkMap = LinkMap.buildLinkMap(parsedTree, markdownText),
baseURI = server?.let { URI(it) }
@@ -35,26 +30,8 @@ class MarkdownToHtmlConverter(
// https://github.com/JetBrains/markdown/issues/72
private val embeddedHtmlType = IElementType("ROOT")
/**
* Parses [markdownText] into the GFM tree [convertMarkdownToHtml] renders from.
*
* Exposed so that a caller which has to agree with the rendered output about what the Markdown *is* — where a
* fenced block starts, whether it has been closed, what its info string says — can read the same tree instead
* of keeping a second grammar of its own. Two grammars disagreeing about a fence is worse than an ordinary
* rendering difference when one of them decides that a block is replaced by a stateful component.
*
* Pass `parseInlines = false` when only the block structure matters. Inline parsing is the bulk of the work
* and the part that degrades on pathological input, so a block-level caller should skip it.
*/
@ApiStatus.Internal
fun parseGfmMarkdownToAst(
@NlsSafe markdownText: CharSequence,
flavour: MarkdownFlavourDescriptor = GFMFlavourDescriptor(),
parseInlines: Boolean = true,
): ASTNode = MarkdownParser(flavour, cancellationToken = CancellationToken.NonCancellable)
.parse(embeddedHtmlType, markdownText, parseInlines)
fun convertMarkdownToHtml(@NlsSafe markdownText: String): @NlsSafe String {
val flavour = GFMFlavourDescriptor()
return HtmlGenerator(markdownText, parseGfmMarkdownToAst(markdownText, flavour), flavour).generateHtml()
val parsedTree = MarkdownParser(flavour).parse(embeddedHtmlType, markdownText)
return HtmlGenerator(markdownText, parsedTree, flavour).generateHtml()
}
@@ -1,122 +0,0 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.markdown.utils
import org.intellij.markdown.IElementType
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* [parseGfmMarkdownToAst] is the syntax authority two callers share: the HTML rendering below, and a caller
* that decides from the same tree which fenced blocks it may replace with a component of its own. Both halves
* are pinned here — the tokens a fence is made of, and the HTML the converter has always produced.
*/
class MarkdownToHtmlConverterTest {
@Test
fun `complete fence exposes its delimiters, info string and content`() {
val fence = singleCodeFence("```kotlin\nval x = 1\n```\n")
assertEquals("```", fence.tokenText(MarkdownTokenTypes.CODE_FENCE_START))
assertEquals("kotlin", fence.tokenText(MarkdownTokenTypes.FENCE_LANG))
assertEquals("val x = 1", fence.tokenText(MarkdownTokenTypes.CODE_FENCE_CONTENT))
assertEquals("```", fence.tokenText(MarkdownTokenTypes.CODE_FENCE_END))
}
/** The line break after the closing delimiter belongs to the enclosing block, not to the fence. */
@Test
fun `fence ends at its closing delimiter`() {
val fence = singleCodeFence("```kotlin\nval x = 1\n```\nafter\n")
assertEquals("```kotlin\nval x = 1\n```", fence.text())
assertEquals('\n', fence.textAfter())
}
@Test
fun `incomplete fence has no end token`() {
val fence = singleCodeFence("```kotlin\nval x = 1\n")
assertEquals("kotlin", fence.tokenText(MarkdownTokenTypes.FENCE_LANG))
assertEquals("val x = 1", fence.tokenText(MarkdownTokenTypes.CODE_FENCE_CONTENT))
assertNull(fence.tokenText(MarkdownTokenTypes.CODE_FENCE_END))
}
/** An opening delimiter with nothing after it yet is already a fence, and carries no line break. */
@Test
fun `fence opened at the end of the text has neither content nor line break`() {
val fence = singleCodeFence("```kotlin")
assertEquals("kotlin", fence.tokenText(MarkdownTokenTypes.FENCE_LANG))
assertNull(fence.tokenText(MarkdownTokenTypes.EOL))
assertNull(fence.tokenText(MarkdownTokenTypes.CODE_FENCE_END))
}
/** The info string is the rest of the opening line, verbatim — trailing spaces included. */
@Test
fun `info string is not trimmed`() {
assertEquals("kotlin ", singleCodeFence("```kotlin \nbody\n```\n").tokenText(MarkdownTokenTypes.FENCE_LANG))
}
/** A blank line inside a fence produces no content token, so content has to be read as a range. */
@Test
fun `blank content line produces no content token`() {
val fence = singleCodeFence("```\nA\n\nB\n```\n")
assertEquals(listOf("A", "B"), fence.tokenTexts(MarkdownTokenTypes.CODE_FENCE_CONTENT))
}
/** A fence in a list item is not a child of the root, which is how a caller can tell it apart. */
@Test
fun `nested fence is not a root level node`() {
val tree = parseGfmMarkdownToAst("- item\n ```kotlin\n body\n ```\n", parseInlines = false)
assertTrue(tree.children.none { it.type == MarkdownElementTypes.CODE_FENCE })
assertEquals(1, tree.codeFences().size)
}
@Test
fun `skipping inline parsing keeps the block structure`() {
val text = "para **bold**\n\n```kotlin\nval x = 1\n```\n"
val withInlines = parseGfmMarkdownToAst(text).codeFences().single()
val withoutInlines = parseGfmMarkdownToAst(text, parseInlines = false).codeFences().single()
assertEquals(withInlines.startOffset, withoutInlines.startOffset)
assertEquals(withInlines.endOffset, withoutInlines.endOffset)
assertEquals(withInlines.children.map(ASTNode::type), withoutInlines.children.map(ASTNode::type))
}
/** Extracting the parser out of the converter must not move the generated HTML. */
@Test
fun `converter output is unchanged`() {
assertEquals(
"<p>Intro</p><pre><code class=\"language-kotlin\">val x = 1\n</code></pre><p>After <strong>bold</strong>.</p>",
convertMarkdownToHtml("Intro\n```kotlin\nval x = 1\n```\nAfter **bold**.\n"),
)
}
private fun singleCodeFence(text: String): CodeFence =
CodeFence(text, parseGfmMarkdownToAst(text, parseInlines = false).codeFences().single())
private class CodeFence(private val text: String, private val node: ASTNode) {
fun text(): String = text.substring(node.startOffset, node.endOffset)
fun textAfter(): Char? = text.getOrNull(node.endOffset)
fun tokenText(type: IElementType): String? = tokenTexts(type).firstOrNull()
fun tokenTexts(type: IElementType): List<String> = node.children
.filter { it.type == type }
.map { text.substring(it.startOffset, it.endOffset) }
}
private fun ASTNode.codeFences(): List<ASTNode> = buildList {
val pending = ArrayDeque(listOf(this@codeFences))
while (pending.isNotEmpty()) {
val node = pending.removeFirst()
if (node.type == MarkdownElementTypes.CODE_FENCE) add(node) else pending.addAll(node.children)
}
}
}