diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c5c67268..42f48da60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ with the exception that 0.x versions can break between minor versions.
## [Unreleased]
### Added
- Allow customizing HTML attributes for alert title `
` tag via `AttributeProvider`
+- Support rendering GFM task list items to Markdown
## [0.28.0] - 2026-03-31
### Added
diff --git a/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/TaskListItemsExtension.java b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/TaskListItemsExtension.java
index b84559273..1c89256d3 100644
--- a/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/TaskListItemsExtension.java
+++ b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/TaskListItemsExtension.java
@@ -1,10 +1,16 @@
package org.commonmark.ext.task.list.items;
+import java.util.Set;
import org.commonmark.Extension;
import org.commonmark.ext.task.list.items.internal.TaskListItemHtmlNodeRenderer;
+import org.commonmark.ext.task.list.items.internal.TaskListItemMarkdownNodeRenderer;
import org.commonmark.ext.task.list.items.internal.TaskListItemPostProcessor;
import org.commonmark.parser.Parser;
+import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlRenderer;
+import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
+import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory;
+import org.commonmark.renderer.markdown.MarkdownRenderer;
/**
* Extension for adding task list items.
@@ -16,7 +22,8 @@
*
* @since 0.15.0
*/
-public class TaskListItemsExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension {
+public class TaskListItemsExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension,
+ MarkdownRenderer.MarkdownRendererExtension {
private TaskListItemsExtension() {
}
@@ -34,4 +41,19 @@ public void extend(Parser.Builder parserBuilder) {
public void extend(HtmlRenderer.Builder rendererBuilder) {
rendererBuilder.nodeRendererFactory(TaskListItemHtmlNodeRenderer::new);
}
+
+ @Override
+ public void extend(MarkdownRenderer.Builder rendererBuilder) {
+ rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() {
+ @Override
+ public NodeRenderer create(MarkdownNodeRendererContext context) {
+ return new TaskListItemMarkdownNodeRenderer(context);
+ }
+
+ @Override
+ public Set getSpecialCharacters() {
+ return Set.of();
+ }
+ });
+ }
}
diff --git a/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemHtmlNodeRenderer.java b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemHtmlNodeRenderer.java
index 331b301e9..a27b125c8 100644
--- a/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemHtmlNodeRenderer.java
+++ b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemHtmlNodeRenderer.java
@@ -2,15 +2,13 @@
import org.commonmark.ext.task.list.items.TaskListItemMarker;
import org.commonmark.node.Node;
-import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlNodeRendererContext;
import org.commonmark.renderer.html.HtmlWriter;
import java.util.LinkedHashMap;
import java.util.Map;
-import java.util.Set;
-public class TaskListItemHtmlNodeRenderer implements NodeRenderer {
+public class TaskListItemHtmlNodeRenderer extends TaskListItemNodeRenderer {
private final HtmlNodeRendererContext context;
private final HtmlWriter html;
@@ -20,11 +18,6 @@ public TaskListItemHtmlNodeRenderer(HtmlNodeRendererContext context) {
this.html = context.getWriter();
}
- @Override
- public Set> getNodeTypes() {
- return Set.of(TaskListItemMarker.class);
- }
-
@Override
public void render(Node node) {
if (node instanceof TaskListItemMarker) {
diff --git a/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemMarkdownNodeRenderer.java b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemMarkdownNodeRenderer.java
new file mode 100644
index 000000000..d2b363952
--- /dev/null
+++ b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemMarkdownNodeRenderer.java
@@ -0,0 +1,36 @@
+package org.commonmark.ext.task.list.items.internal;
+
+import org.commonmark.ext.task.list.items.TaskListItemMarker;
+import org.commonmark.node.Node;
+import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
+import org.commonmark.renderer.markdown.MarkdownWriter;
+
+public class TaskListItemMarkdownNodeRenderer extends TaskListItemNodeRenderer {
+
+ private final MarkdownNodeRendererContext context;
+ private final MarkdownWriter writer;
+
+ public TaskListItemMarkdownNodeRenderer(MarkdownNodeRendererContext context) {
+ this.context = context;
+ this.writer = context.getWriter();
+ }
+
+ @Override
+ public void render(Node node) {
+ if (node instanceof TaskListItemMarker) {
+ var taskListItemNode = (TaskListItemMarker) node;
+ var checkboxFill = taskListItemNode.isChecked() ? "x" : " ";
+ writer.raw("[" + checkboxFill + "] ");
+ renderChildren(node);
+ }
+ }
+
+ private void renderChildren(Node parent) {
+ Node node = parent.getFirstChild();
+ while (node != null) {
+ Node next = node.getNext();
+ context.render(node);
+ node = next;
+ }
+ }
+}
diff --git a/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemNodeRenderer.java b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemNodeRenderer.java
new file mode 100644
index 000000000..24efd4e7d
--- /dev/null
+++ b/commonmark-ext-task-list-items/src/main/java/org/commonmark/ext/task/list/items/internal/TaskListItemNodeRenderer.java
@@ -0,0 +1,13 @@
+package org.commonmark.ext.task.list.items.internal;
+
+import java.util.Set;
+import org.commonmark.ext.task.list.items.TaskListItemMarker;
+import org.commonmark.node.Node;
+import org.commonmark.renderer.NodeRenderer;
+
+public abstract class TaskListItemNodeRenderer implements NodeRenderer {
+ @Override
+ public Set> getNodeTypes() {
+ return Set.of(TaskListItemMarker.class);
+ }
+}
diff --git a/commonmark-ext-task-list-items/src/test/java/org/commonmark/ext/task/list/items/TaskListItemMarkdownRendererTest.java b/commonmark-ext-task-list-items/src/test/java/org/commonmark/ext/task/list/items/TaskListItemMarkdownRendererTest.java
new file mode 100644
index 000000000..cf73f434c
--- /dev/null
+++ b/commonmark-ext-task-list-items/src/test/java/org/commonmark/ext/task/list/items/TaskListItemMarkdownRendererTest.java
@@ -0,0 +1,84 @@
+package org.commonmark.ext.task.list.items;
+
+import java.util.Set;
+import org.commonmark.Extension;
+import org.commonmark.node.BulletList;
+import org.commonmark.node.Document;
+import org.commonmark.node.ListItem;
+import org.commonmark.node.Node;
+import org.commonmark.node.Paragraph;
+import org.commonmark.node.Text;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.markdown.MarkdownRenderer;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class TaskListItemMarkdownRendererTest {
+
+ private static final Set EXTENSIONS = Set.of(TaskListItemsExtension.create());
+ private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build();
+ private static final MarkdownRenderer RENDERER = MarkdownRenderer.builder().extensions(EXTENSIONS).build();
+
+ @Test
+ public void testCheckedRoundTrip() {
+ assertRoundTrip("- [x] I am checked\n");
+ }
+
+ @Test
+ public void testUncheckedRoundTrip() {
+ assertRoundTrip("- [ ] I am unchecked\n");
+ }
+
+ @Test
+ public void testMixedRoundTrip() {
+ assertRoundTrip("- [x] I am checked\n- [ ] I am unchecked\n");
+ }
+
+ @Test
+ public void testNestedRoundTrip() {
+ assertRoundTrip("- [ ] I am unchecked\n - [x] I am a checked child\n");
+ }
+
+ @Test
+ public void testFormattingRoundTrip() {
+ assertRoundTrip("- [x] I am **boldly** checked\n- [ ] I am *italicly* unchecked\n");
+ }
+
+ @Test
+ public void testNonTaskListItemRoundTrip() {
+ assertRoundTrip("- [x] I am checked\n- [ ] I am unchecked\n- I am not a task item\n");
+ }
+
+ @Test
+ public void testOrderedListRoundTrip() {
+ assertRoundTrip("1. [x] I am checked\n2. [ ] I am unchecked\n");
+ }
+
+ @Test
+ public void testProgrammaticallyBuilt() {
+ var doc = new Document();
+ var list = new BulletList();
+ var item = new ListItem();
+ var taskMarker = new TaskListItemMarker(false);
+ var para = new Paragraph();
+ var text = new Text("I am a task");
+ para.appendChild(text);
+ item.appendChild(taskMarker);
+ item.appendChild(para);
+ list.appendChild(item);
+ doc.appendChild(list);
+
+ assertRenderedEquals(doc, "- [ ] I am a task\n");
+ }
+
+ private void assertRoundTrip(String input) {
+ String rendered = RENDERER.render(PARSER.parse(input));
+ assertThat(rendered).isEqualTo(input);
+ }
+
+ private void assertRenderedEquals(Node inputNode, String expectedOutput) {
+ var renderedOutput = RENDERER.render(inputNode);
+ assertThat(renderedOutput).isEqualTo(expectedOutput);
+ }
+}