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); + } +}