diff --git a/.idea/modules.xml b/.idea/modules.xml index 507277ff85732..e9b6b977f584a 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -1255,6 +1255,7 @@ + diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt index d7d04fadb3551..32e135a0faa40 100644 --- a/build/bazel-generated-file-list.txt +++ b/build/bazel-generated-file-list.txt @@ -646,6 +646,7 @@ platform/jewel/int-ui/int-ui-standalone platform/jewel/int-ui/int-ui-standalone-tests platform/jewel/markdown/core platform/jewel/markdown/extensions/autolink +platform/jewel/markdown/extensions/front-matter platform/jewel/markdown/extensions/gfm-alerts platform/jewel/markdown/extensions/gfm-strikethrough platform/jewel/markdown/extensions/gfm-tables diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CommunityModuleSets.kt b/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CommunityModuleSets.kt index d5e6ba323dac1..7ab5db82bb812 100644 --- a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CommunityModuleSets.kt +++ b/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CommunityModuleSets.kt @@ -319,6 +319,7 @@ object CommunityModuleSets { module("intellij.platform.jewel.markdown.extensions.gfmAlerts") module("intellij.platform.jewel.markdown.extensions.gfmTables") module("intellij.platform.jewel.markdown.extensions.gfmStrikethrough") + module("intellij.platform.jewel.markdown.extensions.frontMatter") module("intellij.platform.jewel.markdown.extensions.images") module("intellij.platform.jewel.markdown.core") } diff --git a/platform/compose/markdown/BUILD.bazel b/platform/compose/markdown/BUILD.bazel index 9f7962440b166..af316de30b2c4 100644 --- a/platform/compose/markdown/BUILD.bazel +++ b/platform/compose/markdown/BUILD.bazel @@ -18,6 +18,7 @@ jvm_library( "//platform/jewel/markdown/extensions/autolink", "//platform/jewel/markdown/extensions/gfm-alerts", "//platform/jewel/markdown/extensions/gfm-strikethrough", + "//platform/jewel/markdown/extensions/front-matter", "//platform/jewel/markdown/extensions/gfm-tables", "//platform/jewel/markdown/extensions/images", "//platform/jewel/markdown/ide-laf-bridge-styling", @@ -28,6 +29,7 @@ jvm_library( "//platform/jewel/markdown/extensions/autolink", "//platform/jewel/markdown/extensions/gfm-alerts", "//platform/jewel/markdown/extensions/gfm-strikethrough", + "//platform/jewel/markdown/extensions/front-matter", "//platform/jewel/markdown/extensions/gfm-tables", "//platform/jewel/markdown/extensions/images", "//platform/jewel/markdown/ide-laf-bridge-styling", @@ -51,6 +53,8 @@ jvm_library( "//platform/jewel/markdown/extensions/gfm-alerts:gfm-alerts_test_lib", "//platform/jewel/markdown/extensions/gfm-strikethrough", "//platform/jewel/markdown/extensions/gfm-strikethrough:gfm-strikethrough_test_lib", + "//platform/jewel/markdown/extensions/front-matter", + "//platform/jewel/markdown/extensions/front-matter:front-matter_test_lib", "//platform/jewel/markdown/extensions/gfm-tables", "//platform/jewel/markdown/extensions/gfm-tables:gfm-tables_test_lib", "//platform/jewel/markdown/extensions/images", @@ -65,6 +69,7 @@ jvm_library( "//platform/jewel/markdown/extensions/autolink:autolink_test_lib", "//platform/jewel/markdown/extensions/gfm-alerts:gfm-alerts_test_lib", "//platform/jewel/markdown/extensions/gfm-strikethrough:gfm-strikethrough_test_lib", + "//platform/jewel/markdown/extensions/front-matter:front-matter_test_lib", "//platform/jewel/markdown/extensions/gfm-tables:gfm-tables_test_lib", "//platform/jewel/markdown/extensions/images:images_test_lib", "//platform/jewel/markdown/ide-laf-bridge-styling:ide-laf-bridge-styling_test_lib", diff --git a/platform/compose/markdown/intellij.platform.compose.markdown.iml b/platform/compose/markdown/intellij.platform.compose.markdown.iml index 6af18c0e4ca20..e8b4c3d29235d 100644 --- a/platform/compose/markdown/intellij.platform.compose.markdown.iml +++ b/platform/compose/markdown/intellij.platform.compose.markdown.iml @@ -33,6 +33,7 @@ + diff --git a/platform/compose/markdown/resources/intellij.platform.compose.markdown.xml b/platform/compose/markdown/resources/intellij.platform.compose.markdown.xml index 0b8a412ede405..3c43e474e3e12 100644 --- a/platform/compose/markdown/resources/intellij.platform.compose.markdown.xml +++ b/platform/compose/markdown/resources/intellij.platform.compose.markdown.xml @@ -6,6 +6,7 @@ + diff --git a/platform/jewel/gradle/libs.versions.toml b/platform/jewel/gradle/libs.versions.toml index abb7e33534606..bacef708d89ec 100644 --- a/platform/jewel/gradle/libs.versions.toml +++ b/platform/jewel/gradle/libs.versions.toml @@ -32,6 +32,7 @@ commonmark-core = { module = "org.commonmark:commonmark", version.ref = "commonm commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } +commonmark-ext-yaml-front-matter = { module = "org.commonmark:commonmark-ext-yaml-front-matter", version.ref = "commonmark" } detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } detekt-core = { module = "io.gitlab.arturbosch.detekt:detekt-core", version.ref = "detekt" } detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } diff --git a/platform/jewel/markdown/extensions/front-matter/BUILD.bazel b/platform/jewel/markdown/extensions/front-matter/BUILD.bazel new file mode 100644 index 0000000000000..0beeff5f822fa --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/BUILD.bazel @@ -0,0 +1,83 @@ +### auto-generated section `build intellij.platform.jewel.markdown.extensions.frontMatter` start +load("//build:compiler-options.bzl", "create_kotlinc_options") +load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup") + +create_kotlinc_options( + name = "custom_front-matter", + opt_in = [ + "androidx.compose.ui.ExperimentalComposeUiApi", + "androidx.compose.foundation.ExperimentalFoundationApi", + "org.jetbrains.jewel.foundation.ExperimentalJewelApi", + "org.jetbrains.jewel.foundation.InternalJewelApi", + ], + x_context_parameters = True, + x_explicit_api_mode = "strict" +) + +resourcegroup( + name = "front-matter_resources", + srcs = glob(["src/main/resources/**/*"]), + strip_prefix = "src/main/resources" +) + +jvm_library( + name = "front-matter", + module_name = "intellij.platform.jewel.markdown.extensions.frontMatter", + visibility = ["//visibility:public"], + srcs = glob(["src/main/kotlin/**/*.kt", "src/main/kotlin/**/*.java", "src/main/kotlin/**/*.form"], allow_empty = True), + resources = [":front-matter_resources"], + kotlinc_opts = ":custom_front-matter", + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "@lib//:jetbrains-annotations", + "//platform/jewel/markdown/core", + "//platform/jewel/markdown/extensions/gfm-tables", + "//platform/jewel/ui", + "//platform/jewel/foundation", + "//libraries/compose-foundation-desktop", + "//libraries/compose-runtime-desktop", + ], + plugins = ["@lib//:compose-plugin"] +) + +jvm_library( + name = "front-matter_test_lib", + visibility = ["//visibility:public"], + srcs = glob(["src/test/kotlin/**/*.kt", "src/test/kotlin/**/*.java", "src/test/kotlin/**/*.form"], allow_empty = True), + kotlinc_opts = ":custom_front-matter", + associates = [":front-matter"], + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "//libraries/kotlinx/coroutines/core:core_test_lib", + "@lib//:jetbrains-annotations", + "//platform/jewel/markdown/core", + "//platform/jewel/markdown/core:core_test_lib", + "//platform/jewel/markdown/extensions/gfm-tables", + "//platform/jewel/markdown/extensions/gfm-tables:gfm-tables_test_lib", + "//platform/jewel/ui", + "//platform/jewel/ui:ui_test_lib", + "//platform/jewel/foundation", + "//platform/jewel/foundation:foundation_test_lib", + "//libraries/compose-foundation-desktop", + "//libraries/compose-foundation-desktop:compose-foundation-desktop_test_lib", + "//libraries/compose-runtime-desktop", + "//libraries/compose-runtime-desktop:compose-runtime-desktop_test_lib", + "//libraries/compose-foundation-desktop-junit", + "//libraries/compose-foundation-desktop-junit:compose-foundation-desktop-junit_test_lib", + "//libraries/junit4", + "//libraries/junit4:junit4_test_lib", + ], + plugins = ["@lib//:compose-plugin"] +) +### auto-generated section `build intellij.platform.jewel.markdown.extensions.frontMatter` end + +### auto-generated section `test intellij.platform.jewel.markdown.extensions.frontMatter` start +load("@community//build:tests-options.bzl", "jps_test") + +jps_test( + name = "front-matter_test", + runtime_deps = [":front-matter_test_lib"] +) +### auto-generated section `test intellij.platform.jewel.markdown.extensions.frontMatter` end \ No newline at end of file diff --git a/platform/jewel/markdown/extensions/front-matter/api-dump-experimental.txt b/platform/jewel/markdown/extensions/front-matter/api-dump-experimental.txt new file mode 100644 index 0000000000000..d100ccac3a75a --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/api-dump-experimental.txt @@ -0,0 +1,7 @@ +*f:org.jetbrains.jewel.markdown.extensions.frontmatter.FrontMatterProcessorExtension +- org.jetbrains.jewel.markdown.extensions.MarkdownProcessorExtension +- sf:$stable:I +- sf:INSTANCE:org.jetbrains.jewel.markdown.extensions.frontmatter.FrontMatterProcessorExtension +- getBlockProcessorExtension():org.jetbrains.jewel.markdown.extensions.MarkdownBlockProcessorExtension +- getParserExtension():org.commonmark.parser.Parser$ParserExtension +- getTextRendererExtension():org.commonmark.renderer.text.TextContentRenderer$TextContentRendererExtension diff --git a/platform/jewel/markdown/extensions/front-matter/api-dump.txt b/platform/jewel/markdown/extensions/front-matter/api-dump.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/platform/jewel/markdown/extensions/front-matter/build.gradle.kts b/platform/jewel/markdown/extensions/front-matter/build.gradle.kts new file mode 100644 index 0000000000000..f963410a433f5 --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/build.gradle.kts @@ -0,0 +1,21 @@ +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag + +plugins { + jewel + `jewel-check-public-api` + alias(libs.plugins.composeDesktop) + alias(libs.plugins.compose.compiler) +} + +dependencies { + implementation(projects.markdown.core) + implementation(projects.markdown.extensions.gfmTables) + + testImplementation(compose.desktop.uiTestJUnit4) +} + +publicApiValidation { + excludedClassRegexes = setOf("org.jetbrains.jewel.markdown.extensions.frontmatter.*") +} + +composeCompiler { featureFlags.add(ComposeFeatureFlag.OptimizeNonSkippingGroups) } diff --git a/platform/jewel/markdown/extensions/front-matter/exposed-third-party-api.txt b/platform/jewel/markdown/extensions/front-matter/exposed-third-party-api.txt new file mode 100644 index 0000000000000..0d417a3c3847f --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/exposed-third-party-api.txt @@ -0,0 +1 @@ +org/commonmark/** diff --git a/platform/jewel/markdown/extensions/front-matter/intellij.platform.jewel.markdown.extensions.frontMatter.iml b/platform/jewel/markdown/extensions/front-matter/intellij.platform.jewel.markdown.extensions.frontMatter.iml new file mode 100644 index 0000000000000..c247f0d533e4e --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/intellij.platform.jewel.markdown.extensions.frontMatter.iml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + $MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.3.10-RC/kotlin-compose-compiler-plugin-2.3.10-RC.jar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-api-0.34.0.txt b/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-api-0.34.0.txt new file mode 100644 index 0000000000000..06dabba19c93d --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-api-0.34.0.txt @@ -0,0 +1,12 @@ +// Signature format: 4.0 +package org.jetbrains.jewel.markdown.extensions.frontmatter { + + @SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public final class FrontMatterProcessorExtension implements org.jetbrains.jewel.markdown.extensions.MarkdownProcessorExtension { + property public org.jetbrains.jewel.markdown.extensions.MarkdownBlockProcessorExtension blockProcessorExtension; + property public org.commonmark.parser.Parser.ParserExtension parserExtension; + property public org.commonmark.renderer.text.TextContentRenderer.TextContentRendererExtension textRendererExtension; + field public static final org.jetbrains.jewel.markdown.extensions.frontmatter.FrontMatterProcessorExtension INSTANCE; + } + +} + diff --git a/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-api-stable-0.34.0.txt b/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-api-stable-0.34.0.txt new file mode 100644 index 0000000000000..e6f50d0d0fd11 --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-api-stable-0.34.0.txt @@ -0,0 +1 @@ +// Signature format: 4.0 diff --git a/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-baseline-current.txt b/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-baseline-current.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-baseline-stable-current.txt b/platform/jewel/markdown/extensions/front-matter/metalava/front-matter-baseline-stable-current.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/platform/jewel/markdown/extensions/front-matter/module-content.yaml b/platform/jewel/markdown/extensions/front-matter/module-content.yaml new file mode 100644 index 0000000000000..ac81d8d6922c3 --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/module-content.yaml @@ -0,0 +1,3 @@ +- name: dist.all/lib/intellij.platform.jewel.markdown.extensions.frontMatter.jar + modules: + - name: intellij.platform.jewel.markdown.extensions.frontMatter diff --git a/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterBlock.kt b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterBlock.kt new file mode 100644 index 0000000000000..ea73bacccd00b --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterBlock.kt @@ -0,0 +1,5 @@ +package org.jetbrains.jewel.markdown.extensions.frontmatter + +import org.commonmark.node.CustomBlock + +internal class FrontMatterBlock : CustomBlock() diff --git a/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterBlockParser.kt b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterBlockParser.kt new file mode 100644 index 0000000000000..7b0767a62323f --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterBlockParser.kt @@ -0,0 +1,272 @@ +package org.jetbrains.jewel.markdown.extensions.frontmatter + +import org.commonmark.node.Block +import org.commonmark.parser.block.AbstractBlockParser +import org.commonmark.parser.block.AbstractBlockParserFactory +import org.commonmark.parser.block.BlockContinue +import org.commonmark.parser.block.BlockStart +import org.commonmark.parser.block.MatchedBlockParser +import org.commonmark.parser.block.ParserState + +internal class FrontMatterBlockParser : AbstractBlockParser() { + private val fmBlock = FrontMatterBlock() + + private var currentBlock: CurrentBlock = CurrentBlock.KeyValue() + set(value) { + flushCurrentKey() + field = value + } + + private var done = false + + override fun getBlock(): Block = fmBlock + + override fun tryContinue(state: ParserState): BlockContinue? { + if (done) return BlockContinue.none() + + val line = state.line.content.toString() + + if (line.trimEnd() == "---") { + flushAll() + done = true + return BlockContinue.finished() + } + + if (!parseLine(line)) return BlockContinue.none() + + return BlockContinue.atIndex(state.index) + } + + private fun parseLine(line: String): Boolean = + when (val block = currentBlock) { + is CurrentBlock.KeyValue -> parseKeyLine(line, block) + is CurrentBlock.Scalar -> parseScalarLine(line, block) + } + + private fun parseScalarLine(line: String, block: CurrentBlock.Scalar): Boolean { + val trimmed = line.trimEnd() + + // Detect indent from first non-empty content line + if (block.indent == null) { + if (trimmed.isEmpty()) { + block.lines.add("") + return true + } + val indent = line.indentWidth() + if (indent < block.minimumIndent) { + flushAll() + val keyBlock = currentBlock as? CurrentBlock.KeyValue ?: return false + return parseKeyLine(line, keyBlock) + } + block.indent = indent + } + + val indent = block.indent!! + + // Blank lines are always part of the scalar content + if (trimmed.isEmpty()) { + block.lines.add("") + return true + } + + val lineIndent = line.indentWidth() + if (lineIndent >= indent) { + block.lines.add(line.substring(indent)) + return true + } + + // Line is not indented enough -- end of block scalar + flushAll() + val keyBlock = currentBlock as? CurrentBlock.KeyValue ?: return false + return parseKeyLine(line, keyBlock) + } + + private fun parseKeyLine(line: String, block: CurrentBlock.KeyValue): Boolean { + val trimmed = line.trimEnd() + + if (trimmed.isBlank()) { + return true + } + + val listItemMatch = LIST_ITEM_REGEX.matchEntire(trimmed) + // If a list item, add it to the current block + if (listItemMatch != null) { + if (block.currentKey == null) return false + block.currentValues.add(parseStringValue(listItemMatch.groupValues[1])) + return true + } + + val keyValue = trimmed.split(':', limit = 2) + if (keyValue.size < 2) { + // not a "key: value" pair + return false + } + + val (key, value) = keyValue.map { it.trim() } + + if (key.isBlank() || key.startsWith('-')) { + return false + } + + if (value.isBlank()) { + // Bare "key:" without the "value" -- could be on the next line + currentBlock = CurrentBlock.KeyValue(currentKey = key) + return true + } + + // Check for the block scalar indicator (e.g. "key: >+" + val blockScalarMatch = BLOCK_SCALAR_REGEX.matchEntire(value) + if (blockScalarMatch != null) { + currentBlock = + CurrentBlock.Scalar( + currentKey = key, + indicator = blockScalarMatch.groupValues[1][0], + chomping = blockScalarMatch.groupValues[2].firstOrNull(), + minimumIndent = line.indentWidth() + 1, + ) + return true + } + + // Simple "key: value" + currentBlock = CurrentBlock.KeyValue(currentKey = key, currentValues = mutableListOf(parseStringValue(value))) + return true + } + + private fun flushBlockScalar() { + val block = currentBlock as? CurrentBlock.Scalar ?: return + val indicator = block.indicator + val chomping = block.chomping + val lines = block.lines.toMutableList() + + // Count and remove trailing blank lines + var trailingBlanks = 0 + for (i in lines.lastIndex downTo 0) { + if (lines[i].isNotEmpty()) break + trailingBlanks++ + } + + // Build the content text (without trailing blanks) + if (trailingBlanks == lines.size) { + // Empty block scalar -- no content + currentBlock = + CurrentBlock.KeyValue( + currentKey = block.currentKey, + currentValues = block.currentValues.toMutableList(), + ) + return + } + + val contentLines = lines.subList(0, lines.size - trailingBlanks) + + val contentText = + when (indicator) { + '|' -> contentLines.joinToString("\n") + '>' -> foldLines(contentLines) + else -> contentLines.joinToString("\n") + } + + // Apply chomping to determine trailing newlines + val value = + when (chomping) { + '-' -> contentText + '+' -> contentText + "\n".repeat(trailingBlanks + 1) + else -> contentText + "\n" // clip: single trailing newline + } + + currentBlock = CurrentBlock.KeyValue(currentKey = block.currentKey, currentValues = mutableListOf(value)) + } + + private fun foldLines(lines: List): String { + return buildString { + var i = 0 + var hasTextOnLine = false + while (i < lines.size) { + val line = lines[i] + if (line.isEmpty()) { + // Blank line -- paragraph break + appendLine() + appendLine() + hasTextOnLine = false + // Skip consecutive blank lines + while (i + 1 < lines.size && lines[i + 1].isEmpty()) { + i++ + } + } else { + if (hasTextOnLine) { + append(" ") + } + append(line) + hasTextOnLine = !line.endsWith("\n") + } + i++ + } + } + } + + private fun flushCurrentKey() { + val block = currentBlock as? CurrentBlock.KeyValue ?: return + val key = block.currentKey ?: return + val node = FrontMatterNode(key, block.currentValues.toList()) + fmBlock.appendChild(node) + block.currentKey = null + block.currentValues.clear() + } + + override fun closeBlock() { + flushAll() + } + + private fun flushAll() { + flushBlockScalar() + flushCurrentKey() + } + + private sealed interface CurrentBlock { + class KeyValue(var currentKey: String? = null, var currentValues: MutableList = mutableListOf()) : + CurrentBlock + + class Scalar( + var currentKey: String, + var currentValues: MutableList = mutableListOf(), + var indicator: Char, + var chomping: Char?, + var minimumIndent: Int = 1, + var indent: Int? = null, + var lines: MutableList = mutableListOf(), + ) : CurrentBlock + } + + internal class Factory : AbstractBlockParserFactory() { + override fun tryStart(state: ParserState, matchedBlockParser: MatchedBlockParser): BlockStart? { + // Front matter must be at the very beginning of the document + val parent = matchedBlockParser.matchedBlockParser.block + if (parent !is org.commonmark.node.Document) return BlockStart.none() + if (parent.firstChild != null) return BlockStart.none() + + val line = state.line.content.toString() + if (state.nextNonSpaceIndex != 0) return BlockStart.none() + + if (line.trimEnd() == "---") { + return BlockStart.of(FrontMatterBlockParser()).atIndex(state.line.content.length) + } + return BlockStart.none() + } + } + + companion object { + private val BLOCK_SCALAR_REGEX = Regex("^([|>])([+-]?)$") + private val LIST_ITEM_REGEX = Regex("^\\s*-\\s+(.+)$") + + private fun parseStringValue(raw: String): String { + val trimmed = raw.trim() + if (trimmed.length >= 2 && (trimmed.surroundedBy('\'') || trimmed.surroundedBy('"'))) { + return trimmed.substring(1, trimmed.length - 1) + } + return trimmed + } + + private fun String.indentWidth(): Int = indexOfFirst { !it.isWhitespace() }.let { if (it == -1) length else it } + + private fun String.surroundedBy(char: Char): Boolean = first() == char && last() == char + } +} diff --git a/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterNode.kt b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterNode.kt new file mode 100644 index 0000000000000..b713cc82961cc --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterNode.kt @@ -0,0 +1,5 @@ +package org.jetbrains.jewel.markdown.extensions.frontmatter + +import org.commonmark.node.CustomNode + +internal class FrontMatterNode(val key: String, val values: List) : CustomNode() diff --git a/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterProcessorExtension.kt b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterProcessorExtension.kt new file mode 100644 index 0000000000000..7c114eb8e14eb --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterProcessorExtension.kt @@ -0,0 +1,111 @@ +package org.jetbrains.jewel.markdown.extensions.frontmatter + +import org.commonmark.node.CustomBlock +import org.commonmark.node.Node +import org.commonmark.parser.Parser.Builder +import org.commonmark.parser.Parser.ParserExtension +import org.commonmark.renderer.text.TextContentRenderer +import org.commonmark.renderer.text.TextContentRenderer.TextContentRendererExtension +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.markdown.InlineMarkdown +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.extensions.MarkdownBlockProcessorExtension +import org.jetbrains.jewel.markdown.extensions.MarkdownProcessorExtension +import org.jetbrains.jewel.markdown.extensions.github.tables.TableBlock +import org.jetbrains.jewel.markdown.extensions.github.tables.TableCell +import org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader +import org.jetbrains.jewel.markdown.extensions.github.tables.TableRow +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor + +/** + * Adds support for YAML front matter metadata blocks. Front matter is a common way to add metadata to Markdown + * documents, delimited by `---` markers at the beginning of the file. + * + * Front matter metadata is rendered as a two-row table: the first row (header) contains the keys and the second row + * contains the values. When a value is a list, it is rendered as a single-row nested table within the cell. + */ +@ApiStatus.Experimental +@ExperimentalJewelApi +public object FrontMatterProcessorExtension : MarkdownProcessorExtension { + override val parserExtension: ParserExtension = FrontMatterParserExtension + override val textRendererExtension: TextContentRendererExtension = FrontMatterParserExtension + + override val blockProcessorExtension: MarkdownBlockProcessorExtension = FrontMatterBlockProcessorExtension + + private object FrontMatterBlockProcessorExtension : MarkdownBlockProcessorExtension { + override fun canProcess(block: CustomBlock): Boolean = block is FrontMatterBlock + + override fun processMarkdownBlock( + block: CustomBlock, + processor: MarkdownProcessor, + ): MarkdownBlock.CustomBlock? { + val frontMatterBlock = block as FrontMatterBlock + val entries = frontMatterBlock.collectEntries() + if (entries.isEmpty()) return null + + val headerCells = + entries.mapIndexed { index, (key, _) -> + TableCell(rowIndex = 0, columnIndex = index, content = textAsCellContent(key), alignment = null) + } + + val valueCells = + entries.mapIndexed { index, (_, values) -> createValueCell(columnIndex = index, values = values) } + val dataRow = TableRow(rowIndex = 0, cells = valueCells) + + return TableBlock(header = TableHeader(headerCells), rows = listOf(dataRow)) + } + + private fun createValueCell(columnIndex: Int, values: List): TableCell = + if (values.size <= 1) { + TableCell( + rowIndex = 1, + columnIndex = columnIndex, + content = textAsCellContent(values.firstOrNull().orEmpty()), + alignment = null, + ) + } else { + // Multiple values are rendered as a headerless nested table (single data row) + val valueCells = + values.mapIndexed { index, value -> + TableCell( + rowIndex = 0, + columnIndex = index, + content = textAsCellContent(value), + alignment = null, + ) + } + val nestedTable = TableBlock(header = null, rows = listOf(TableRow(rowIndex = 0, cells = valueCells))) + TableCell(rowIndex = 1, columnIndex = columnIndex, content = nestedTable, alignment = null) + } + + private fun textAsCellContent(value: String): MarkdownBlock = + MarkdownBlock.Paragraph(listOf(InlineMarkdown.Text(value))) + + private fun FrontMatterBlock.collectEntries(): List>> = buildList { + forEachChild { child -> + if (child is FrontMatterNode) { + add(child.key to child.values) + } + } + } + + private inline fun Node.forEachChild(action: (Node) -> Unit) { + var child = firstChild + while (child != null) { + action(child) + child = child.next + } + } + } +} + +private object FrontMatterParserExtension : ParserExtension, TextContentRendererExtension { + override fun extend(parserBuilder: Builder) { + parserBuilder.customBlockParserFactory(FrontMatterBlockParser.Factory()) + } + + override fun extend(rendererBuilder: TextContentRenderer.Builder) { + // No-op: front matter is not rendered as text + } +} diff --git a/platform/jewel/markdown/extensions/front-matter/src/main/resources/intellij.platform.jewel.markdown.extensions.frontMatter.xml b/platform/jewel/markdown/extensions/front-matter/src/main/resources/intellij.platform.jewel.markdown.extensions.frontMatter.xml new file mode 100644 index 0000000000000..3eb4b1a92d1b7 --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/src/main/resources/intellij.platform.jewel.markdown.extensions.frontMatter.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/platform/jewel/markdown/extensions/front-matter/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterProcessorExtensionTest.kt b/platform/jewel/markdown/extensions/front-matter/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterProcessorExtensionTest.kt new file mode 100644 index 0000000000000..5a13383f94d49 --- /dev/null +++ b/platform/jewel/markdown/extensions/front-matter/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/frontmatter/FrontMatterProcessorExtensionTest.kt @@ -0,0 +1,523 @@ +package org.jetbrains.jewel.markdown.extensions.frontmatter + +import org.jetbrains.jewel.markdown.InlineMarkdown +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.extensions.github.tables.TableBlock +import org.jetbrains.jewel.markdown.extensions.github.tables.TableCell +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(org.jetbrains.jewel.foundation.ExperimentalJewelApi::class) +public class FrontMatterProcessorExtensionTest { + private val processor = MarkdownProcessor(listOf(FrontMatterProcessorExtension)) + + @Test + public fun `simple key-value front matter is parsed as two-row table`() { + val rawMarkdown = + """ + |--- + |title: Hello World + |author: John Doe + |--- + | + |Some content. + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(2, table.columnCount) + assertEquals(1, table.rowCount - 1) + + // Verify header contains keys + assertCellText("title", table.header.asNotNull().cells[0]) + assertCellText("author", table.header.asNotNull().cells[1]) + + // Verify single data row contains values + assertCellText("Hello World", table.rows[0].cells[0]) + assertCellText("John Doe", table.rows[0].cells[1]) + } + + @Test + public fun `single key front matter is parsed as two-row table`() { + val rawMarkdown = + """ + |--- + |title: My Document + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(1, table.columnCount) + assertEquals(1, table.rowCount - 1) + assertCellText("title", table.header.asNotNull().cells[0]) + assertCellText("My Document", table.rows[0].cells[0]) + } + + @Test + public fun `empty front matter produces no block`() { + val rawMarkdown = + """ + |--- + |--- + | + |Some content. + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + // Empty front matter should be skipped, only the paragraph should remain + assertTrue(blocks.none { it is TableBlock }) + } + + @Test + public fun `list values are rendered as single-row nested table`() { + val rawMarkdown = + """ + |--- + |tags: + | - kotlin + | - compose + | - jewel + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(1, table.columnCount) + assertEquals(1, table.rowCount - 1) + + // Header should contain the key + assertCellText("tags", table.header.asNotNull().cells[0]) + + // Value cell should contain a nested table + val valueCell = table.rows[0].cells[0] + val nestedTable = valueCell.content.assertIs() + assertEquals(3, nestedTable.columnCount) + assertNull(nestedTable.header) + assertEquals(1, nestedTable.rowCount) + assertCellText("kotlin", nestedTable.rows[0].cells[0]) + assertCellText("compose", nestedTable.rows[0].cells[1]) + assertCellText("jewel", nestedTable.rows[0].cells[2]) + } + + @Test + public fun `mixed scalar and list values`() { + val rawMarkdown = + """ + |--- + |title: My Post + |tags: + | - one + | - two + |author: Someone + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(3, table.columnCount) + assertEquals(1, table.rowCount - 1) + + // Header contains all keys + assertCellText("title", table.header.asNotNull().cells[0]) + assertCellText("tags", table.header.asNotNull().cells[1]) + assertCellText("author", table.header.asNotNull().cells[2]) + + // Scalar value — inline content + assertCellText("My Post", table.rows[0].cells[0]) + table.rows[0].cells[0].content.assertIs() + + // List value — block content with nested single-row table + val nestedTable = table.rows[0].cells[1].content.assertIs() + assertEquals(2, nestedTable.columnCount) + assertNull(nestedTable.header) + assertCellText("one", nestedTable.rows[0].cells[0]) + assertCellText("two", nestedTable.rows[0].cells[1]) + + // Scalar value after list + assertCellText("Someone", table.rows[0].cells[2]) + } + + @Test + public fun `front matter parsing stops on non-front-matter syntax`() { + val rawMarkdown = + """ + |--- + |title: Test + |# Heading that should not be part of front matter + | + |Paragraph text. + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks[0].assertIs() + assertEquals(1, table.columnCount) + assertCellText("title", table.header.asNotNull().cells[0]) + assertCellText("Test", table.rows[0].cells[0]) + + blocks[1].assertIs() + blocks[2].assertIs() + } + + @Test + public fun `content after front matter is preserved`() { + val rawMarkdown = + """ + |--- + |title: Test + |--- + | + |# Heading + | + |Paragraph text. + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + blocks[0].assertIs() + blocks[1].assertIs() + blocks[2].assertIs() + } + + @Test + public fun `front matter not at document start is not parsed`() { + val rawMarkdown = + """ + |Some content first. + | + |--- + |title: Test + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + // When front matter is not at the start, the --- are treated as thematic breaks + assertTrue(blocks.none { it is TableBlock }) + } + + @Test + public fun `front matter with many keys produces a table`() { + val rawMarkdown = + """ + |--- + |key1: value1 + |key2: value2 + |key3: + | - item1 + | - item2 + | - item3 + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(3, table.columnCount) + assertEquals(1, table.rowCount - 1) + + // Header row: keys + assertCellText("key1", table.header.asNotNull().cells[0]) + assertCellText("key2", table.header.asNotNull().cells[1]) + assertCellText("key3", table.header.asNotNull().cells[2]) + + // Data row: values + assertCellText("value1", table.rows[0].cells[0]) + assertCellText("value2", table.rows[0].cells[1]) + + // List value: single-row nested table + val listCell = table.rows[0].cells[2] + val nestedTable = listCell.content.assertIs() + assertEquals(3, nestedTable.columnCount) + assertNull(nestedTable.header) + assertEquals(1, nestedTable.rowCount) + assertCellText("item1", nestedTable.rows[0].cells[0]) + assertCellText("item2", nestedTable.rows[0].cells[1]) + assertCellText("item3", nestedTable.rows[0].cells[2]) + } + + @Test + public fun `list items with colon are treated as plain text values`() { + val rawMarkdown = + """ + |--- + |tags: + | - team: platform + | - owner: jewel + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(1, table.columnCount) + assertCellText("tags", table.header.asNotNull().cells[0]) + + val valueCell = table.rows[0].cells[0] + val nestedTable = valueCell.content.assertIs() + assertEquals(2, nestedTable.columnCount) + assertNull(nestedTable.header) + assertEquals(1, nestedTable.rowCount) + assertCellText("team: platform", nestedTable.rows[0].cells[0]) + assertCellText("owner: jewel", nestedTable.rows[0].cells[1]) + } + + @Test + public fun `literal block scalar preserves newlines`() { + val rawMarkdown = + """ + |--- + |description: | + | Line one + | Line two + | Line three + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("description", table.header.asNotNull().cells[0]) + assertCellText("Line one\nLine two\nLine three\n", table.rows[0].cells[0]) + } + + @Test + public fun `literal block scalar preserves leading blank lines`() { + val rawMarkdown = + """ + |--- + |description: | + | + | First line + | Second line + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("description", table.header.asNotNull().cells[0]) + assertCellText("\nFirst line\nSecond line\n", table.rows[0].cells[0]) + } + + @Test + public fun `literal block scalar with strip chomping`() { + val rawMarkdown = + """ + |--- + |description: |- + | Line one + | Line two + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("Line one\nLine two", table.rows[0].cells[0]) + } + + @Test + public fun `literal block scalar with keep chomping`() { + val rawMarkdown = + """ + |--- + |description: |+ + | Line one + | Line two + | + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("Line one\nLine two\n\n", table.rows[0].cells[0]) + } + + @Test + public fun `folded block scalar joins consecutive lines`() { + val rawMarkdown = + """ + |--- + |description: > + | This is a long + | description that + | spans multiple lines + | + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("This is a long description that spans multiple lines\n", table.rows[0].cells[0]) + } + + @Test + public fun `folded block scalar with strip chomping`() { + val rawMarkdown = + """ + |--- + |description: >- + | This is a long + | description that + | spans multiple lines + | + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("This is a long description that spans multiple lines", table.rows[0].cells[0]) + } + + @Test + public fun `folded block scalar with keep chomping`() { + val rawMarkdown = + """ + |--- + |description: >+ + | Line one + | Line two + | + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("Line one Line two\n\n", table.rows[0].cells[0]) + } + + @Test + public fun `folded block scalar with blank lines creates paragraph breaks`() { + val rawMarkdown = + """ + |--- + |description: > + | Paragraph one + | continued. + | + | Paragraph two. + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("Paragraph one continued.\n\nParagraph two.\n", table.rows[0].cells[0]) + } + + @Test + public fun `empty block scalar followed by end marker`() { + val rawMarkdown = + """ + |--- + |title: Test + |description: | + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(2, table.columnCount) + assertCellText("title", table.header.asNotNull().cells[0]) + assertCellText("description", table.header.asNotNull().cells[1]) + assertCellText("Test", table.rows[0].cells[0]) + assertCellText("", table.rows[0].cells[1]) + } + + @Test + public fun `blank folded strip scalar followed by key starts a new key-value pair`() { + val rawMarkdown = + """ + |--- + |description: >- + | + |name: Hello World! + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(2, table.columnCount) + assertCellText("description", table.header.asNotNull().cells[0]) + assertCellText("name", table.header.asNotNull().cells[1]) + assertCellText("", table.rows[0].cells[0]) + assertCellText("Hello World!", table.rows[0].cells[1]) + } + + @Test + public fun `block scalar followed by another key`() { + val rawMarkdown = + """ + |--- + |description: | + | Some text + | More text + |author: Jane + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertEquals(2, table.columnCount) + assertCellText("description", table.header.asNotNull().cells[0]) + assertCellText("author", table.header.asNotNull().cells[1]) + assertCellText("Some text\nMore text\n", table.rows[0].cells[0]) + assertCellText("Jane", table.rows[0].cells[1]) + } + + @Test + public fun `quoted values are unquoted`() { + val rawMarkdown = + """ + |--- + |title: "Hello World" + |subtitle: 'Single Quoted' + |--- + """ + .trimMargin() + val blocks = processor.processMarkdownDocument(rawMarkdown) + + val table = blocks.first().assertIs() + assertCellText("Hello World", table.rows[0].cells[0]) + assertCellText("Single Quoted", table.rows[0].cells[1]) + } + + private fun assertCellText(expected: String, cell: TableCell) { + val paragraph = cell.content.assertIs() + val textContent = paragraph.inlineContent.filterIsInstance() + assertTrue(textContent.isNotEmpty()) + assertEquals(expected, textContent.first().content) + } + + private fun T?.asNotNull(): T { + assertNotNull(this) + @Suppress("UNCHECKED_CAST") + return this as T + } + + private inline fun Any.assertIs(): T { + assertTrue( + "An instance of ${this::class.qualifiedName} cannot be cast to ${T::class.qualifiedName}: $this", + this is T, + ) + return this as T + } +} diff --git a/platform/jewel/markdown/extensions/gfm-tables/api-dump-experimental.txt b/platform/jewel/markdown/extensions/gfm-tables/api-dump-experimental.txt index f55441de29a95..8083fcc3e88db 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/api-dump-experimental.txt +++ b/platform/jewel/markdown/extensions/gfm-tables/api-dump-experimental.txt @@ -48,3 +48,55 @@ - s:getEntries():kotlin.enums.EnumEntries - s:valueOf(java.lang.String):org.jetbrains.jewel.markdown.extensions.github.tables.RowBackgroundStyle - s:values():org.jetbrains.jewel.markdown.extensions.github.tables.RowBackgroundStyle[] +*f:org.jetbrains.jewel.markdown.extensions.github.tables.TableBlock +- org.jetbrains.jewel.markdown.MarkdownBlock$CustomBlock +- sf:$stable:I +- (org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader,java.util.List):V +- f:component1():org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader +- f:component2():java.util.List +- f:copy(org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader,java.util.List):org.jetbrains.jewel.markdown.extensions.github.tables.TableBlock +- bs:copy$default(org.jetbrains.jewel.markdown.extensions.github.tables.TableBlock,org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader,java.util.List,I,java.lang.Object):org.jetbrains.jewel.markdown.extensions.github.tables.TableBlock +- equals(java.lang.Object):Z +- f:getColumnCount():I +- f:getHeader():org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader +- f:getRowCount():I +- f:getRows():java.util.List +- hashCode():I +*f:org.jetbrains.jewel.markdown.extensions.github.tables.TableCell +- org.jetbrains.jewel.markdown.MarkdownBlock$CustomBlock +- sf:$stable:I +- (I,I,java.util.List,androidx.compose.ui.Alignment$Horizontal):V +- f:component1():I +- f:component2():I +- f:component3():java.util.List +- f:component4():androidx.compose.ui.Alignment$Horizontal +- f:copy(I,I,java.util.List,androidx.compose.ui.Alignment$Horizontal):org.jetbrains.jewel.markdown.extensions.github.tables.TableCell +- bs:copy$default(org.jetbrains.jewel.markdown.extensions.github.tables.TableCell,I,I,java.util.List,androidx.compose.ui.Alignment$Horizontal,I,java.lang.Object):org.jetbrains.jewel.markdown.extensions.github.tables.TableCell +- equals(java.lang.Object):Z +- f:getAlignment():androidx.compose.ui.Alignment$Horizontal +- f:getColumnIndex():I +- f:getContent():java.util.List +- f:getRowIndex():I +- hashCode():I +*f:org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader +- org.jetbrains.jewel.markdown.MarkdownBlock$CustomBlock +- sf:$stable:I +- (java.util.List):V +- f:component1():java.util.List +- f:copy(java.util.List):org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader +- bs:copy$default(org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader,java.util.List,I,java.lang.Object):org.jetbrains.jewel.markdown.extensions.github.tables.TableHeader +- equals(java.lang.Object):Z +- f:getCells():java.util.List +- hashCode():I +*f:org.jetbrains.jewel.markdown.extensions.github.tables.TableRow +- org.jetbrains.jewel.markdown.MarkdownBlock$CustomBlock +- sf:$stable:I +- (I,java.util.List):V +- f:component1():I +- f:component2():java.util.List +- f:copy(I,java.util.List):org.jetbrains.jewel.markdown.extensions.github.tables.TableRow +- bs:copy$default(org.jetbrains.jewel.markdown.extensions.github.tables.TableRow,I,java.util.List,I,java.lang.Object):org.jetbrains.jewel.markdown.extensions.github.tables.TableRow +- equals(java.lang.Object):Z +- f:getCells():java.util.List +- f:getRowIndex():I +- hashCode():I diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt index 62ec428e1fab8..1a202d4c20c55 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt @@ -15,7 +15,6 @@ import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.foundation.layout.BasicTableLayout import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.MarkdownBlock.CustomBlock import org.jetbrains.jewel.markdown.extensions.MarkdownBlockRendererExtension import org.jetbrains.jewel.markdown.rendering.InlineMarkdownRenderer @@ -56,8 +55,8 @@ internal class GitHubTableBlockRenderer( val semiboldInlinesStyling = rootStyling.paragraph.inlinesStyling.withFontWeight(tableStyling.headerBaseFontWeight) - // Given cells can only contain inlines, and not block-level nodes, we are ok with - // only overriding the Paragraph styling. + // Header cells produced by table parsing are represented as Paragraph blocks, + // so overriding Paragraph styling is enough here. MarkdownStyling( rootStyling.blockVerticalSpacing, MarkdownStyling.Paragraph(semiboldInlinesStyling), @@ -75,52 +74,17 @@ internal class GitHubTableBlockRenderer( val rows = remember(tableBlock, blockRenderer, inlineRenderer, tableStyling) { - val headerCells = - tableBlock.header.cells.map Unit> { cell -> - { - Cell( - cell = cell, - backgroundColor = tableStyling.colors.rowBackgroundColor, - padding = tableStyling.metrics.cellPadding, - defaultAlignment = tableStyling.metrics.headerDefaultCellContentAlignment, - blockRenderer = headerRenderer, - enabled = enabled, - paragraphStyling = headerRenderer.rootStyling.paragraph, - onUrlClick = onUrlClick, - ) - } + buildList Unit>> { + if (tableBlock.header != null) { + add(headerCells(tableBlock.header, headerRenderer, enabled, onUrlClick)) } - val rowsCells = - tableBlock.rows.map Unit>> { row -> - row.cells.map Unit> { cell -> - { - val backgroundColor = - if (tableStyling.colors.rowBackgroundStyle == RowBackgroundStyle.Striped) { - if (cell.rowIndex % 2 == 0) { - tableStyling.colors.alternateRowBackgroundColor - } else { - tableStyling.colors.rowBackgroundColor - } - } else { - tableStyling.colors.rowBackgroundColor - } - - Cell( - cell = cell, - backgroundColor = backgroundColor, - padding = tableStyling.metrics.cellPadding, - defaultAlignment = tableStyling.metrics.defaultCellContentAlignment, - blockRenderer = blockRenderer, - enabled = enabled, - paragraphStyling = rootStyling.paragraph, - onUrlClick = onUrlClick, - ) - } + val rowsCells = + tableBlock.rows.map Unit>> { row -> + rowCells(row, blockRenderer, enabled, onUrlClick) } - } - - listOf(headerCells) + rowsCells + addAll(rowsCells) + } } BasicTableLayout( @@ -133,6 +97,57 @@ internal class GitHubTableBlockRenderer( ) } + private fun headerCells( + header: TableHeader, + headerRenderer: MarkdownBlockRenderer, + enabled: Boolean, + onUrlClick: (String) -> Unit, + ): List<@Composable (() -> Unit)> = + header.cells.map { + { + Cell( + cell = it, + backgroundColor = tableStyling.colors.rowBackgroundColor, + padding = tableStyling.metrics.cellPadding, + defaultAlignment = tableStyling.metrics.headerDefaultCellContentAlignment, + blockRenderer = headerRenderer, + enabled = enabled, + onUrlClick = onUrlClick, + ) + } + } + + private fun rowCells( + row: TableRow, + blockRenderer: MarkdownBlockRenderer, + enabled: Boolean, + onUrlClick: (String) -> Unit, + ): List<@Composable (() -> Unit)> = + row.cells.map Unit> { cell -> + { + val backgroundColor = + if (tableStyling.colors.rowBackgroundStyle == RowBackgroundStyle.Striped) { + if (cell.rowIndex % 2 == 1) { + tableStyling.colors.alternateRowBackgroundColor + } else { + tableStyling.colors.rowBackgroundColor + } + } else { + tableStyling.colors.rowBackgroundColor + } + + Cell( + cell = cell, + backgroundColor = backgroundColor, + padding = tableStyling.metrics.cellPadding, + defaultAlignment = tableStyling.metrics.defaultCellContentAlignment, + blockRenderer = blockRenderer, + enabled = enabled, + onUrlClick = onUrlClick, + ) + } + } + private fun InlinesStyling.withFontWeight(newFontWeight: FontWeight) = InlinesStyling( textStyle = textStyle.copy(fontWeight = newFontWeight), @@ -156,20 +171,13 @@ internal class GitHubTableBlockRenderer( defaultAlignment: Alignment.Horizontal, blockRenderer: MarkdownBlockRenderer, enabled: Boolean, - paragraphStyling: MarkdownStyling.Paragraph, onUrlClick: (String) -> Unit, ) { Box( modifier = Modifier.background(backgroundColor).padding(padding).clipToBounds(), contentAlignment = (cell.alignment ?: defaultAlignment).asContentAlignment(), ) { - blockRenderer.RenderParagraph( - block = MarkdownBlock.Paragraph(cell.content), - styling = paragraphStyling, - enabled = enabled, - onUrlClick = onUrlClick, - modifier = Modifier, - ) + blockRenderer.RenderBlock(cell.content, enabled, onUrlClick, Modifier) } } diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableProcessorExtension.kt b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableProcessorExtension.kt index 4088a6cdabf79..5f64ea88e1820 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableProcessorExtension.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableProcessorExtension.kt @@ -64,7 +64,7 @@ public object GitHubTableProcessorExtension : MarkdownProcessorExtension { TableCell( rowIndex = 0, columnIndex = columnIndex, - content = cell.readInlineMarkdown(processor), + content = inlinesAsCellContent(cell.readInlineMarkdown(processor)), alignment = getAlignment(cell), ) } @@ -77,7 +77,7 @@ public object GitHubTableProcessorExtension : MarkdownProcessorExtension { TableCell( rowIndex = rowIndex + 1, // The header is row zero columnIndex = columnIndex, - content = cell.readInlineMarkdown(processor), + content = inlinesAsCellContent(cell.readInlineMarkdown(processor)), alignment = getAlignment(cell), ) }, @@ -133,6 +133,8 @@ private object GitHubTablesCommonMarkExtension : ParserExtension, TextContentRen } } +private fun inlinesAsCellContent(inlines: List): MarkdownBlock = MarkdownBlock.Paragraph(inlines) + private object GitHubTablesHtmlConverterExtension : MarkdownHtmlConverterExtension { override val supportedTags: Set get() = setOf("table") @@ -151,14 +153,18 @@ private object GitHubTablesHtmlConverterExtension : MarkdownHtmlConverterExtensi val htmlRows = tbody.children.filterIsInstance().filter { it.tag == "tr" } if (htmlRows.isEmpty()) return null val markdownRows: List> = buildList { - for (i in 0..htmlRows.lastIndex) { + for ((i, element) in htmlRows.withIndex()) { add( - htmlRows[i] + element .rowElements() .mapIndexed { index, rowCell -> val inlines = convertInlines(rowCell.children) - if (inlines.isEmpty()) return@mapIndexed TableCell(i, index, emptyList(), null) - TableCell(rowIndex = i, columnIndex = index, content = inlines, alignment = null) + TableCell( + rowIndex = i, + columnIndex = index, + content = inlinesAsCellContent(inlines), + alignment = null, + ) } .toMutableList() ) @@ -167,7 +173,14 @@ private object GitHubTablesHtmlConverterExtension : MarkdownHtmlConverterExtensi val maxColumns = markdownRows.maxOf { it.size } for ((rowIndex, row) in markdownRows.withIndex()) { for (columnIndex in row.size until maxColumns) { - row.add(TableCell(rowIndex, columnIndex, emptyList(), null)) + row.add( + TableCell( + rowIndex = rowIndex, + columnIndex = columnIndex, + content = inlinesAsCellContent(emptyList()), + alignment = null, + ) + ) } } val header = TableHeader(markdownRows.first()) diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableBlock.kt b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableBlock.kt index eacaeb1188a72..610c770f321a7 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableBlock.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableBlock.kt @@ -1,22 +1,33 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.jetbrains.jewel.markdown.extensions.github.tables +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.markdown.MarkdownBlock -internal data class TableBlock(val header: TableHeader, val rows: List) : MarkdownBlock.CustomBlock { - val rowCount: Int = rows.size + 1 // We always have a header +@ApiStatus.Experimental +@ExperimentalJewelApi +public data class TableBlock(val header: TableHeader?, val rows: List) : MarkdownBlock.CustomBlock { + val rowCount: Int = rows.size + if (header != null) 1 else 0 val columnCount: Int init { - require(header.cells.isNotEmpty()) { "Header cannot be empty" } - val headerColumns = header.cells.size + if (header != null) { + require(header.cells.isNotEmpty()) { "Header cannot be empty" } + } + require(header != null || rows.isNotEmpty()) { "Table must have a header or at least one row" } - if (rows.isNotEmpty()) { - val bodyColumns = rows.first().cells.size - require(rows.all { it.cells.size == bodyColumns }) { "Inconsistent cell count in table body" } + val headerColumns = header?.cells?.size + val bodyColumns = rows.firstOrNull()?.cells?.size + + if (headerColumns != null && bodyColumns != null) { require(headerColumns == bodyColumns) { "Inconsistent cell count between table body and header" } } + if (rows.isNotEmpty()) { + val firstRowSize = rows.first().cells.size + require(rows.all { it.cells.size == firstRowSize }) { "Inconsistent cell count in table body" } + } - columnCount = headerColumns + columnCount = headerColumns ?: bodyColumns ?: error("Table must have at least one row or header") } } diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableCell.kt b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableCell.kt index 480efbca94f87..58aead70d4f89 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableCell.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableCell.kt @@ -2,12 +2,15 @@ package org.jetbrains.jewel.markdown.extensions.github.tables import androidx.compose.ui.Alignment -import org.jetbrains.jewel.markdown.InlineMarkdown +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.markdown.MarkdownBlock -internal data class TableCell( +@ApiStatus.Experimental +@ExperimentalJewelApi +public data class TableCell( val rowIndex: Int, val columnIndex: Int, - val content: List, + val content: MarkdownBlock, val alignment: Alignment.Horizontal?, ) : MarkdownBlock.CustomBlock diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableHeader.kt b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableHeader.kt index 09ae42b450359..ba0ea9ae5cd70 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableHeader.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableHeader.kt @@ -1,6 +1,10 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.jetbrains.jewel.markdown.extensions.github.tables +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.markdown.MarkdownBlock -internal data class TableHeader(val cells: List) : MarkdownBlock.CustomBlock +@ApiStatus.Experimental +@ExperimentalJewelApi +public data class TableHeader(val cells: List) : MarkdownBlock.CustomBlock diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableRow.kt b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableRow.kt index 7c0b9a4a4a8d7..39413f0989da1 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableRow.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/TableRow.kt @@ -1,6 +1,10 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.jetbrains.jewel.markdown.extensions.github.tables +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.markdown.MarkdownBlock -internal data class TableRow(val rowIndex: Int, val cells: List) : MarkdownBlock.CustomBlock +@ApiStatus.Experimental +@ExperimentalJewelApi +public data class TableRow(val rowIndex: Int, val cells: List) : MarkdownBlock.CustomBlock diff --git a/platform/jewel/samples/standalone/BUILD.bazel b/platform/jewel/samples/standalone/BUILD.bazel index fcb5abbca2a7b..a6562515b824a 100644 --- a/platform/jewel/samples/standalone/BUILD.bazel +++ b/platform/jewel/samples/standalone/BUILD.bazel @@ -1,3 +1,12 @@ +load("@rules_java//java:defs.bzl", "java_binary") + +java_binary( + name = "standalone_run", + main_class = "org.jetbrains.jewel.samples.standalone.MainKt", + visibility = ["//visibility:public"], + runtime_deps = [":standalone"], +) + ### auto-generated section `build intellij.platform.jewel.samples.standalone` start load("//build:compiler-options.bzl", "create_kotlinc_options") load("@rules_jvm//:jvm.bzl", "jvm_library", "resourcegroup") @@ -43,6 +52,7 @@ jvm_library( "//platform/jewel/markdown/extensions/gfm-alerts", "//platform/jewel/markdown/extensions/gfm-tables", "//platform/jewel/markdown/extensions/gfm-strikethrough", + "//platform/jewel/markdown/extensions/front-matter", "//libraries/coil", "//platform/jewel/markdown/extensions/images", "//platform/jewel/int-ui/int-ui-standalone:jewel-intUi-standalone", @@ -79,6 +89,7 @@ jvm_library( "//platform/jewel/markdown/extensions/gfm-alerts:gfm-alerts_test_lib", "//platform/jewel/markdown/extensions/gfm-tables:gfm-tables_test_lib", "//platform/jewel/markdown/extensions/gfm-strikethrough:gfm-strikethrough_test_lib", + "//platform/jewel/markdown/extensions/front-matter:front-matter_test_lib", "//libraries/coil:coil_test_lib", "//platform/jewel/markdown/extensions/images:images_test_lib", "//platform/jewel/int-ui/int-ui-standalone:jewel-intUi-standalone_test_lib", diff --git a/platform/jewel/samples/standalone/build.gradle.kts b/platform/jewel/samples/standalone/build.gradle.kts index 4b36f6f0222ef..9a85a1fb7e60f 100644 --- a/platform/jewel/samples/standalone/build.gradle.kts +++ b/platform/jewel/samples/standalone/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(projects.markdown.extensions.autolink) implementation(projects.markdown.extensions.gfmAlerts) implementation(projects.markdown.extensions.gfmStrikethrough) + implementation(projects.markdown.extensions.frontMatter) implementation(projects.markdown.extensions.gfmTables) implementation(projects.markdown.extensions.images) implementation(projects.markdown.intUiStandaloneStyling) diff --git a/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml b/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml index 2e62fda7c28b7..7f16b24788563 100644 --- a/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml +++ b/platform/jewel/samples/standalone/intellij.platform.jewel.samples.standalone.iml @@ -45,6 +45,7 @@ + diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/markdown/MarkdownPreview.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/markdown/MarkdownPreview.kt index af4c2a331db40..0775347320c59 100644 --- a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/markdown/MarkdownPreview.kt +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/markdown/MarkdownPreview.kt @@ -32,6 +32,7 @@ import org.jetbrains.jewel.intui.markdown.standalone.styling.light import org.jetbrains.jewel.markdown.LazyMarkdown import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.extensions.autolink.AutolinkProcessorExtension +import org.jetbrains.jewel.markdown.extensions.frontmatter.FrontMatterProcessorExtension import org.jetbrains.jewel.markdown.extensions.github.alerts.AlertStyling import org.jetbrains.jewel.markdown.extensions.github.alerts.GitHubAlertProcessorExtension import org.jetbrains.jewel.markdown.extensions.github.alerts.GitHubAlertRendererExtension @@ -63,6 +64,7 @@ internal fun MarkdownPreview(rawMarkdown: CharSequence, modifier: Modifier = Mod MarkdownProcessor( listOf( AutolinkProcessorExtension, + FrontMatterProcessorExtension, GitHubAlertProcessorExtension, GitHubStrikethroughProcessorExtension(), GitHubTableProcessorExtension, diff --git a/platform/jewel/samples/standalone/src/main/resources/intellij.platform.jewel.samples.standalone.xml b/platform/jewel/samples/standalone/src/main/resources/intellij.platform.jewel.samples.standalone.xml index 671c2dc57a669..5629f6c737edc 100644 --- a/platform/jewel/samples/standalone/src/main/resources/intellij.platform.jewel.samples.standalone.xml +++ b/platform/jewel/samples/standalone/src/main/resources/intellij.platform.jewel.samples.standalone.xml @@ -9,6 +9,7 @@ + diff --git a/platform/jewel/settings.gradle.kts b/platform/jewel/settings.gradle.kts index 2931a57bff13f..e7637c96a97d7 100644 --- a/platform/jewel/settings.gradle.kts +++ b/platform/jewel/settings.gradle.kts @@ -43,6 +43,7 @@ include( ":markdown:extensions:autolink", ":markdown:extensions:gfm-alerts", ":markdown:extensions:gfm-strikethrough", + ":markdown:extensions:front-matter", ":markdown:extensions:gfm-tables", ":markdown:extensions:images", ":markdown:int-ui-standalone-styling", diff --git a/platform/platform-resources/generated/META-INF/intellij.moduleSets.compose.xml b/platform/platform-resources/generated/META-INF/intellij.moduleSets.compose.xml index 9cbc2280cdef4..d6aaccc0b3a96 100644 --- a/platform/platform-resources/generated/META-INF/intellij.moduleSets.compose.xml +++ b/platform/platform-resources/generated/META-INF/intellij.moduleSets.compose.xml @@ -16,6 +16,7 @@ + diff --git a/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml b/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml index 018a4ab7f4a1e..36efa4a1b3afa 100644 --- a/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml +++ b/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml @@ -302,6 +302,7 @@ +