diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 336bb815a..ea50c83e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kobweb = "0.23.2-SNAPSHOT" #------------------------ -commonmark = "0.24.0" +commonmark = "0.25.1" compose = "1.8.0" dokka = "2.0.0" gradle-publish = "1.3.0" @@ -24,8 +24,10 @@ vanniktech-publish = "0.34.0" [libraries] commonmark-core = { module = "org.commonmark:commonmark", version.ref = "commonmark" } commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } commonmark-tasklist = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" } +commonmark-footnotes = { module = "org.commonmark:commonmark-ext-footnotes", version.ref = "commonmark" } compose-html-core = { module = "org.jetbrains.compose.html:html-core", version.ref = "compose" } compose-compiler-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } @@ -62,7 +64,14 @@ truthish = { module = "com.varabyte.truthish:truthish", version.ref = "truthish" vanniktech-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "vanniktech-publish" } [bundles] -commonmark = ["commonmark-core", "commonmark-autolink", "commonmark-tables", "commonmark-tasklist"] +commonmark = [ + "commonmark-core", + "commonmark-autolink", + "commonmark-tables", + "commonmark-tasklist", + "commonmark-strikethrough", + "commonmark-footnotes", +] ktor = [ "ktor-serialization-json", "ktor-server-auth", diff --git a/playground/site/src/jsMain/resources/markdown/Markdown.md b/playground/site/src/jsMain/resources/markdown/Markdown.md index 902872fb2..4db7324c7 100644 --- a/playground/site/src/jsMain/resources/markdown/Markdown.md +++ b/playground/site/src/jsMain/resources/markdown/Markdown.md @@ -6,7 +6,7 @@ This site is generated from Markdown. Create rich, dynamic web apps with ease, leveraging [Kotlin](https://kotlinlang.org/) and [Compose HTML](https://github.com/JetBrains/compose-multiplatform#compose-html). -Markdown of course supports **bold**, _italic_, and _**bold italic**_ text. +Markdown of course supports **bold**, _italic_, _**bold italic**_, and ~~strikethrough~~ text. It also supports @@ -45,6 +45,7 @@ You can link to other Markdown documents with their route overrides resolved cor [documents/INDEX.md](documents/INDEX.md)
[KotlinLanguage.md](jetbrains/KotlinLanguage.md) (`routeOverride: languages/kotlin`)
[documents/Bananas.md](documents/Bananas.md) (`routeOverride: /fruits/`)
+[tests/unified_test.md](tests/unified_test.md) (`routeOverride: /example/`)
[files/external.md](/files/external.md) (Linking to a Markdown file outside of the processed markdown files)
You can use inlined html tags. You can inspect this page to see that "inlined html" is diff --git a/playground/site/src/jsMain/resources/markdown/tests/unified_test.md b/playground/site/src/jsMain/resources/markdown/tests/unified_test.md new file mode 100644 index 000000000..4d1d7ef4f --- /dev/null +++ b/playground/site/src/jsMain/resources/markdown/tests/unified_test.md @@ -0,0 +1,98 @@ +--- +title: Unified Markdown Processing Test +date: 'July 8, 2025' +description: Testing unified markdown processing with remark-gfm and rehype-raw plugins +tags: [ 'kobweb', 'commonmark', 'gfm', 'test', 'md', 'kotlin' ] +posted: true +--- + +# Unified Markdown Processing Test + +This content demonstrates markdown features on the **Kobweb Framework**, which uses +a [CommonMark](https://commonmark.org/) parser +with [GitHub Flavored Markdown (GFM) extensions](https://github.github.com/gfm/). + +## GitHub Flavored Markdown Features + +### Strikethrough + +~~This text should be struck through~~ or ~one tilde~ + +### Tables +| Feature | Status | Notes | +|------------------|------------------------------------|-------------------------------| +| Strikethrough | * [x] | Crosses out text | +| Tables | [x] | Organizes data in a grid | +| Task Lists |- [x] | Creates checklists | +| HTML in Markdown | - [x] highlighted text | Allows embedding raw HTML tags | +| Code Blocks | - [x] | Allows embedding code blocks | +| Links | - [x] | Allows embedding links | +| Emphasis | - [x] | Allows embedding emphasis | +| Column A | Column B | Column C | Column D | +|----------| :--------- |:--------:|---------:| +| Left | Left | Center | Right | +| Data 1 | Data 2 | Data 3 | Data 4 | +| Row 2 | Row 2 | Row 2 | Row 2 | + +### Task Lists + +- [x] Completed task +- [ ] Pending task +- [x] Another completed task +- [ ] Another pending task +- [x] Venus +- works + +### HTML Elements in Markdown + +This should work with underlined text and highlighted text. + +
+Note: This is HTML content inside markdown that should be preserved with rehype-raw. +
+ +### Code Blocks + +```javascript +// This is a JavaScript code block +function hello() { + console.log("Hello from unified markdown processing!"); +} +``` + +### Links and Autolinks + +Regular link: [Kobweb Documentation](https://kobweb.varabyte.com) + +Autolink: https://kobweb.varabyte.com and www.example.com. + +### Emphasis and Strong + +*Italic text* and **bold text** and ***bold italic text*** + +## Footnote + +A note can have a simple footnote.[^1] Here are some more examples: + +- A footnote with a link.[^link] +- A footnote with multiple paragraphs.[^multi] +- A footnote with a long text.[^long] + +[^1]: This is the first simple footnote. + +[^link]: This footnote contains a link to [Kobweb](https://kobweb.varabyte.com). + +[^multi]: This footnote has multiple paragraphs. +[foo]: https://example.com/foo + +This is the second paragraph of the footnote. + +[^long]: Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem +aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim +ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione +voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, +sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. + +--- + +✅ If you can see all the above features working correctly, then the CommonMark-java parser with its GFM extensions is functioning properly! \ No newline at end of file diff --git a/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/KotlinRenderer.kt b/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/KotlinRenderer.kt index 0404792c8..e2b1f2b53 100644 --- a/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/KotlinRenderer.kt +++ b/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/KotlinRenderer.kt @@ -12,13 +12,16 @@ import com.varabyte.kobwebx.gradle.markdown.frontmatter.FrontMatterBlock import com.varabyte.kobwebx.gradle.markdown.handlers.MarkdownHandlers import com.varabyte.kobwebx.gradle.markdown.handlers.NodeScope import com.varabyte.kobwebx.gradle.markdown.util.NodeCache -import com.varabyte.kobwebx.gradle.markdown.util.escapeQuotes import com.varabyte.kobwebx.gradle.markdown.util.unescapeQuotes +import org.commonmark.ext.footnotes.FootnoteDefinition +import org.commonmark.ext.footnotes.FootnoteReference +import org.commonmark.ext.gfm.strikethrough.Strikethrough import org.commonmark.ext.gfm.tables.TableBlock import org.commonmark.ext.gfm.tables.TableBody import org.commonmark.ext.gfm.tables.TableCell import org.commonmark.ext.gfm.tables.TableHead import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.ext.task.list.items.TaskListItemMarker import org.commonmark.node.AbstractVisitor import org.commonmark.node.BlockQuote import org.commonmark.node.BulletList @@ -339,6 +342,7 @@ class KotlinRenderer internal constructor( override fun visit(code: Code) { doVisit(code, handlers.inlineCode) } + override fun visit(emphasis: Emphasis) { doVisit(emphasis, handlers.em) @@ -397,7 +401,7 @@ class KotlinRenderer internal constructor( val route = Route( frontMatterData?.routeOverride - ?: nodeCache.metadata.getValue(destinationNode).routeWithSlug!! // Guaranteed set for a page + ?: nodeCache.metadata.getValue(destinationNode).routeWithSlug!! // Guaranteed set for a page ) if (route.isDynamic) { error("Markdown file link '${link.destination}' links to file with dynamic route override. This is not supported!") @@ -434,6 +438,10 @@ class KotlinRenderer internal constructor( } } + private fun visit(strikethrough: Strikethrough) { + doVisit(strikethrough, handlers.strikethrough) + } + override fun visit(strongEmphasis: StrongEmphasis) { doVisit(strongEmphasis, handlers.strong) } @@ -441,6 +449,39 @@ class KotlinRenderer internal constructor( override fun visit(text: Text) { doVisit(text, handlers.text) } + override fun visit(customBlock: CustomBlock) { + when (customBlock) { + is KobwebCallBlock -> { + val visitor = KobwebCallBlockVisitor() + customBlock.accept(visitor) + visitor.call?.let { call -> + visit(call) + visitor.childrenNodes?.let { children -> + ++indentCount + children.forEach { node -> node.accept(this) } + --indentCount + output.appendLine("$indent}") + } + } + } + + is FrontMatterBlock -> { + // No-op. We don't need to do anything here because we already handled parsing front matter earlier. + } + + is TableBlock -> visit(customBlock) + is FootnoteDefinition -> { + doVisit(customBlock, handlers.footnoteDefinition) + } + + else -> { + val simple = customBlock::class.simpleName + val unhandledBlockName = simple!! + reporter.warn("Unhandled Markdown custom block: $unhandledBlockName. Consider reporting this at: https://github.com/varabyte/kobweb/issues/new?labels=bug&template=bug_report.md&title=Unhandled%20Markdown%20block%20%22$unhandledBlockName%22") + + } + } + } override fun visit(customNode: CustomNode) { when (customNode) { @@ -448,10 +489,13 @@ class KotlinRenderer internal constructor( output.appendLine("$indent${customNode.toFqn(projectGroup)}") } + is Strikethrough -> visit(customNode) is TableHead -> visit(customNode) is TableBody -> visit(customNode) is TableRow -> visit(customNode) is TableCell -> visit(customNode) + is FootnoteReference -> visit(customNode) + is TaskListItemMarker -> visit(customNode) else -> { val unhandledNodeName = customNode::class.simpleName!! @@ -460,6 +504,10 @@ class KotlinRenderer internal constructor( } } + private fun visit(footnoteReference: FootnoteReference) { + doVisit(footnoteReference, handlers.footnoteReference) + } + private fun visit(table: TableBlock) { doVisit(table, handlers.table) } @@ -483,34 +531,8 @@ class KotlinRenderer internal constructor( doVisit(tableCell, handlers.td) } } - - override fun visit(customBlock: CustomBlock) { - when (customBlock) { - is KobwebCallBlock -> { - val visitor = KobwebCallBlockVisitor() - customBlock.accept(visitor) - visitor.call?.let { call -> - visit(call) - visitor.childrenNodes?.let { children -> - ++indentCount - children.forEach { node -> node.accept(this) } - --indentCount - output.appendLine("$indent}") - } - } - } - - is FrontMatterBlock -> { - // No-op. We don't need to do anything here because we already handled parsing front matter earlier. - } - - is TableBlock -> visit(customBlock) - - else -> { - val unhandledBlockName = customBlock::class.simpleName!! - reporter.warn("Unhandled Markdown custom block: $unhandledBlockName. Consider reporting this at: https://github.com/varabyte/kobweb/issues/new?labels=bug&template=bug_report.md&title=Unhandled%20Markdown%20block%20%22$unhandledBlockName%22") - } - } + private fun visit(taskListItemMarker: TaskListItemMarker) { + doVisit(taskListItemMarker, handlers.taskListItemMarker) } } } diff --git a/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/MarkdownFeatures.kt b/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/MarkdownFeatures.kt index ea4f8d470..550d59964 100644 --- a/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/MarkdownFeatures.kt +++ b/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/MarkdownFeatures.kt @@ -6,6 +6,8 @@ import com.varabyte.kobwebx.gradle.markdown.ext.kobwebcall.KobwebCallExtension import com.varabyte.kobwebx.gradle.markdown.frontmatter.FrontMatterExtension import org.commonmark.Extension import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.footnotes.FootnotesExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension import org.commonmark.ext.gfm.tables.TablesExtension import org.commonmark.ext.task.list.items.TaskListItemsExtension import org.commonmark.parser.IncludeSourceSpans @@ -37,6 +39,16 @@ abstract class MarkdownFeatures { @get:Input abstract val autolink: Property + + /** + * If true, support footnotes like "Main text[^1]" with definitions "[^1]: Footnote text". + * + * Defaults to `true`. + * + * Note: Inline footnotes via `^[inline]` are not supported at this time. + */ + @get:Input + abstract val footnotes: Property /** * If true, support front matter (a header YAML block at the top of your markdown file with key/value pairs). * @@ -73,6 +85,15 @@ abstract class MarkdownFeatures { */ @get:Input abstract val kobwebCallDelimiters: Property> + /** + * If true, support GFM strikethrough syntax using double tildes, e.g. `~~text~~`. + * + * Defaults to `true`. + * + * @see Strikethrough + */ + @get:Input + abstract val strikethrough: Property /** * If true, support creating tables via pipe syntax. @@ -100,11 +121,14 @@ abstract class MarkdownFeatures { @get:Input abstract val taskList: Property + init { autolink.convention(true) + footnotes.convention(true) frontMatter.convention(true) kobwebCall.convention(true) kobwebCallDelimiters.convention('{' to '}') + strikethrough.convention(true) tables.convention(true) taskList.convention(true) } @@ -117,12 +141,18 @@ abstract class MarkdownFeatures { if (autolink.get()) { extensions.add(AutolinkExtension.create()) } + if (footnotes.get()) { + extensions.add(FootnotesExtension.create()) + } if (frontMatter.get()) { extensions.add(FrontMatterExtension.create()) } if (kobwebCall.get()) { extensions.add(KobwebCallExtension.create(kobwebCallDelimiters.get()) { createParser() }) } + if (strikethrough.get()) { + extensions.add(StrikethroughExtension.create()) + } if (tables.get()) { extensions.add(TablesExtension.create()) } @@ -130,6 +160,7 @@ abstract class MarkdownFeatures { extensions.add(TaskListItemsExtension.create()) } + return Parser.builder() .extensions(extensions) .includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES) diff --git a/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/handlers/MarkdownHandlers.kt b/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/handlers/MarkdownHandlers.kt index 8bb8b95ff..3e6ab4161 100644 --- a/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/handlers/MarkdownHandlers.kt +++ b/tools/gradle-plugins/extensions/markdown/src/main/kotlin/com/varabyte/kobwebx/gradle/markdown/handlers/MarkdownHandlers.kt @@ -1,5 +1,14 @@ @file:Suppress("LeakingThis") // Following official Gradle guidance +/** + * Enhanced Markdown handlers for Kobweb projects. + * + * Recent enhancements: + * - Task list support: Automatically detects and renders task list items (- [x] / - [ ]) in all text contexts, + * including table cells where CommonMark doesn't normally recognize them as TaskListItemMarker nodes. + * Uses FontAwesome icons when Silk is available, falls back to HTML input checkboxes otherwise. + */ + package com.varabyte.kobwebx.gradle.markdown.handlers import com.varabyte.kobweb.common.collect.Key @@ -12,11 +21,15 @@ import com.varabyte.kobwebx.gradle.markdown.util.escapeDollars import com.varabyte.kobwebx.gradle.markdown.util.escapeQuotes import com.varabyte.kobwebx.gradle.markdown.util.escapeTripleQuotes import com.varabyte.kobwebx.gradle.markdown.util.nestedLiteral +import org.commonmark.ext.footnotes.FootnoteDefinition +import org.commonmark.ext.footnotes.FootnoteReference +import org.commonmark.ext.gfm.strikethrough.Strikethrough import org.commonmark.ext.gfm.tables.TableBlock import org.commonmark.ext.gfm.tables.TableBody import org.commonmark.ext.gfm.tables.TableCell import org.commonmark.ext.gfm.tables.TableHead import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.ext.task.list.items.TaskListItemMarker import org.commonmark.node.BlockQuote import org.commonmark.node.BulletList import org.commonmark.node.Code @@ -79,6 +92,28 @@ class NodeScope(val reporter: Reporter, val data: TypedMap, private val indentCo * } * } * } + * + * ## Task List Support + * + * This handler automatically detects and renders task list items (checkboxes) in markdown text, + * including those inside table cells where CommonMark doesn't automatically recognize them as TaskListItemMarker nodes. + * + * Task list patterns like `- [x]` (checked) and `- [ ]` (unchecked) are converted to: + * - **With Silk**: FontAwesome icons (`FaSquareCheck` for checked, `FaSquare` for unchecked) + * - **Without Silk**: HTML input checkboxes (disabled, with proper checked state) + * + * This works in all contexts including: + * - Regular paragraphs + * - Table cells + * - List items + * - Any other text nodes + * + * Example: + * ```markdown + * | Feature | Status | + * |---------|--------| + * | Task A | - [x] | + * | Task B | - [ ] | * ``` */ abstract class MarkdownHandlers @Inject constructor(project: Project) { @@ -140,57 +175,69 @@ abstract class MarkdownHandlers @Inject constructor(project: Project) { abstract val idGenerator: Property<(String) -> String> @get:Nested - abstract val text: Property String> - @get:Nested - abstract val img: Property String> - @get:Nested - abstract val heading: Property String> + abstract val a: Property String> @get:Nested - abstract val p: Property String> + abstract val blockquote: Property String> @get:Nested abstract val br: Property String> @get:Nested - abstract val a: Property String> + abstract val code: Property String> @get:Nested abstract val em: Property String> @get:Nested - abstract val strong: Property String> + abstract val footnoteDefinition: Property String> + @get:Nested + abstract val footnoteReference: Property String> + + @get:Nested + abstract val heading: Property String> + @get:Nested abstract val hr: Property String> @get:Nested - abstract val ul: Property String> + abstract val html: Property String> @get:Nested - abstract val ol: Property String> + abstract val img: Property String> + @get:Nested + abstract val inlineCode: Property String> + + @get:Nested + abstract val inlineTag: Property String> + @get:Nested abstract val li: Property String> @get:Nested - abstract val code: Property String> + abstract val ol: Property String> @get:Nested - abstract val inlineCode: Property String> + abstract val p: Property String> @get:Nested - abstract val blockquote: Property String> + abstract val rawTag: Property String> + @get:Nested + abstract val strikethrough: Property String> + @get:Nested + abstract val strong: Property String> + @get:Nested abstract val table: Property String> @get:Nested - abstract val thead: Property String> + abstract val taskListItemMarker: Property String> @get:Nested abstract val tbody: Property String> @get:Nested - abstract val tr: Property String> - @get:Nested abstract val td: Property String> @get:Nested - abstract val th: Property String> + abstract val text: Property String> - /** Handler which is fed the raw text (name and attributes) within an opening tag, e.g. `span id="demo"` */ @get:Nested - abstract val rawTag: Property String> + abstract val th: Property String> @get:Nested - abstract val inlineTag: Property String> - + abstract val thead: Property String> @get:Nested - abstract val html: Property String> + abstract val tr: Property String> + @get:Nested + abstract val ul: Property String> + /** Handler which is fed the raw text (name and attributes) within an opening tag, e.g. `span id="demo"` */ fun String.escapeSingleQuotedText() = escapeQuotes().escapeDollars() fun String.escapeTripleQuotedText() = escapeDollars().escapeTripleQuotes() @@ -258,7 +305,11 @@ abstract class MarkdownHandlers @Inject constructor(project: Project) { // region Markdown Node handlers - text.convention { text -> "$JB_DOM.Text(\"${text.literal.escapeSingleQuotedText()}\")" } + text.convention { text -> + val literal = text.literal + // Standard text rendering + "$JB_DOM.Text(\"${literal.escapeSingleQuotedText()}\")" + } img.convention { image -> processImage(image) { data -> buildString { @@ -299,6 +350,8 @@ abstract class MarkdownHandlers @Inject constructor(project: Project) { } em.convention { "$JB_DOM.Em" } strong.convention { "$JB_DOM.B" } + // Compose HTML does not expose a Del composable; use a generic tag to match HTML semantics. + strikethrough.convention { "$KOBWEB_DOM.GenericTag(\"del\")" } hr.convention { if (useSilk.get()) { "$SILK.layout.HorizontalDivider" @@ -436,6 +489,30 @@ abstract class MarkdownHandlers @Inject constructor(project: Project) { sb.toString() } + taskListItemMarker.convention { marker -> + val isChecked = marker.isChecked + if (useSilk.get()) { + val iconCode = if (isChecked) { + "com.varabyte.kobweb.silk.components.icons.fa.FaSquareCheck" + } else { + "com.varabyte.kobweb.silk.components.icons.fa.FaSquare" + } + "$KOBWEB_DOM.GenericTag(\"span\", \"style=\\\"margin-right: 0.5em;\\\"\") { $iconCode() }" + } else { + val checkedAttr = if (isChecked) "checked" else "" + "$KOBWEB_DOM.GenericTag(\"input\", \"type=\\\"checkbox\\\" disabled $checkedAttr style=\\\"margin-right: 0.5em;\\\"\")" + } + } + + footnoteDefinition.convention { definition -> + val label = definition.label + "$KOBWEB_DOM.GenericTag(\"div\", \"class=\\\"footnote-item\\\" id=\\\"fn-$label\\\"\")" + } + + footnoteReference.convention { reference -> + val label = reference.label + "$KOBWEB_DOM.GenericTag(\"sup\", \"id=\\\"fnref-$label\\\"\") { $KOBWEB_DOM.GenericTag(\"a\", \"href=\\\"#fn-$label\\\"\") { $JB_DOM.Text(\"$label\") } }" + } // endregion } }