From 76bdcd18408cb7081f81dc80cb52d2132c9470dc Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Tue, 9 Sep 2025 21:08:38 -0700 Subject: [PATCH] G2-1770 Support multi-line list items & nested lists during MD rendering --- .../document/convert/md/IMDRenderContext.java | 9 ++- .../document/convert/md/MDRenderer.java | 69 +++++++++++-------- .../md/linebreak/ILineBreakStrategy.java | 10 +++ .../md/{ => linebreak}/LineBreakStrategy.java | 21 ++++-- .../md/linebreak/ListLineBreakStrategy.java | 27 ++++++++ .../enigma/document/convert/md/TestMD.java | 31 ++++++--- .../enigma/document/convert/md/lists.md | 5 ++ 7 files changed, 127 insertions(+), 45 deletions(-) create mode 100644 en-document/src/main/java/com/g2forge/enigma/document/convert/md/linebreak/ILineBreakStrategy.java rename en-document/src/main/java/com/g2forge/enigma/document/convert/md/{ => linebreak}/LineBreakStrategy.java (60%) create mode 100644 en-document/src/main/java/com/g2forge/enigma/document/convert/md/linebreak/ListLineBreakStrategy.java create mode 100644 en-document/src/test/java/com/g2forge/enigma/document/convert/md/lists.md diff --git a/en-document/src/main/java/com/g2forge/enigma/document/convert/md/IMDRenderContext.java b/en-document/src/main/java/com/g2forge/enigma/document/convert/md/IMDRenderContext.java index e1ee454..09f62ae 100644 --- a/en-document/src/main/java/com/g2forge/enigma/document/convert/md/IMDRenderContext.java +++ b/en-document/src/main/java/com/g2forge/enigma/document/convert/md/IMDRenderContext.java @@ -2,13 +2,18 @@ import com.g2forge.alexandria.java.close.ICloseable; import com.g2forge.enigma.backend.convert.textual.ITextualRenderContext; +import com.g2forge.enigma.document.convert.md.linebreak.ILineBreakStrategy; public interface IMDRenderContext extends ITextualRenderContext { - public LineBreakStrategy getLineBreakStrategy(); + public ILineBreakStrategy getLineBreakStrategy(); public int getSectionLevel(); - public ICloseable openLineBreakStrategy(LineBreakStrategy strategy); + public int getIndentLevel(); + + public ICloseable openLineBreakStrategy(ILineBreakStrategy strategy); public ICloseable openSection(); + + public ICloseable openIndent(); } diff --git a/en-document/src/main/java/com/g2forge/enigma/document/convert/md/MDRenderer.java b/en-document/src/main/java/com/g2forge/enigma/document/convert/md/MDRenderer.java index d3d4587..d03ca38 100644 --- a/en-document/src/main/java/com/g2forge/enigma/document/convert/md/MDRenderer.java +++ b/en-document/src/main/java/com/g2forge/enigma/document/convert/md/MDRenderer.java @@ -4,8 +4,10 @@ import com.g2forge.alexandria.java.close.ICloseable; import com.g2forge.alexandria.java.core.enums.EnumException; +import com.g2forge.alexandria.java.function.IConsumer1; import com.g2forge.alexandria.java.function.IConsumer2; import com.g2forge.alexandria.java.function.IFunction1; +import com.g2forge.alexandria.java.function.ISupplier; import com.g2forge.alexandria.java.type.function.TypeSwitch1; import com.g2forge.enigma.backend.ITextAppender; import com.g2forge.enigma.backend.convert.ARenderer; @@ -13,6 +15,8 @@ import com.g2forge.enigma.backend.convert.IRendering; import com.g2forge.enigma.backend.convert.textual.ATextualRenderer; import com.g2forge.enigma.backend.text.model.modifier.TextNestedModified; +import com.g2forge.enigma.document.convert.md.linebreak.ILineBreakStrategy; +import com.g2forge.enigma.document.convert.md.linebreak.LineBreakStrategy; import com.g2forge.enigma.document.model.Block; import com.g2forge.enigma.document.model.Definition; import com.g2forge.enigma.document.model.DocList; @@ -34,23 +38,19 @@ @RequiredArgsConstructor public class MDRenderer extends ATextualRenderer { protected class MDRenderContext extends ARenderContext implements IMDRenderContext { - protected final Stack lineBreakStrategies = new Stack<>(); - protected final Stack stack = new Stack<>(); - public MDRenderContext(TextNestedModified.TextNestedModifiedBuilder builder) { - super(builder); - } + @Getter + protected ILineBreakStrategy lineBreakStrategy = LineBreakStrategy.None; - @Override - public LineBreakStrategy getLineBreakStrategy() { - if (lineBreakStrategies.isEmpty()) return LineBreakStrategy.None; - return lineBreakStrategies.peek(); - } + @Getter + protected int sectionLevel = 1; - @Override - public int getSectionLevel() { - return stack.size() + 1; + @Getter + protected int indentLevel; + + public MDRenderContext(TextNestedModified.TextNestedModifiedBuilder builder) { + super(builder); } @Override @@ -58,28 +58,38 @@ protected IMDRenderContext getThis() { return this; } - @Override - public ICloseable openLineBreakStrategy(LineBreakStrategy strategy) { - lineBreakStrategies.push(strategy); - final int size = lineBreakStrategies.size(); - return () -> { - if (lineBreakStrategies.size() != size) throw new IllegalStateException(); - lineBreakStrategies.pop(); - }; - } - - @Override - public ICloseable openSection() { + protected ICloseable open(ISupplier start, IConsumer1 stop) { + final T value = start.get(); final ICloseable retVal = new ICloseable() { @Override public void close() { if (stack.peek() != this) throw new IllegalArgumentException(); stack.pop(); + stop.accept(value); } }; stack.push(retVal); return retVal; } + + @Override + public ICloseable openLineBreakStrategy(ILineBreakStrategy strategy) { + return open(() -> { + final ILineBreakStrategy retVal = lineBreakStrategy; + lineBreakStrategy = strategy; + return retVal; + }, prev -> lineBreakStrategy = prev); + } + + @Override + public ICloseable openSection() { + return open(() -> sectionLevel++, prev -> sectionLevel = prev); + } + + @Override + public ICloseable openIndent() { + return open(() -> indentLevel++, prev -> indentLevel = prev); + } } protected static class MDRendering extends ARenderer.ARendering> { @@ -127,11 +137,11 @@ protected void extend(TypeSwitch1.FunctionBuilder c -> { - try (final ICloseable strategy = c.openLineBreakStrategy(LineBreakStrategy.fromBlockType(md.getType()))) { + try (final ICloseable strategy = c.openLineBreakStrategy(LineBreakStrategy.fromBlockType(c, md.getType()))) { boolean first = true; for (IBlock content : md.getContents()) { if (first) first = false; - else c.getLineBreakStrategy().beforeItem(c, first); + else c.getLineBreakStrategy().beforeItem(c, first, content); c.render(content, IBlock.class); } } @@ -139,6 +149,7 @@ protected void extend(TypeSwitch1.FunctionBuilder c -> { final java.util.List items = md.getItems(); for (int i = 0; i < items.size(); i++) { + for (int j = 0; j < c.getIndentLevel(); j++) c.append(" "); switch (md.getMarker()) { case Ordered: c.append("* "); @@ -147,7 +158,9 @@ protected void extend(TypeSwitch1.FunctionBuilder 0) return new ListLineBreakStrategy(base); + return base; } - public void beforeItem(IMDRenderContext context, boolean first) {} + public void beforeItem(IMDRenderContext context, boolean first, IDocListItem item) {} public void text(IMDRenderContext context, String text) { context.append(text); diff --git a/en-document/src/main/java/com/g2forge/enigma/document/convert/md/linebreak/ListLineBreakStrategy.java b/en-document/src/main/java/com/g2forge/enigma/document/convert/md/linebreak/ListLineBreakStrategy.java new file mode 100644 index 0000000..0a57af5 --- /dev/null +++ b/en-document/src/main/java/com/g2forge/enigma/document/convert/md/linebreak/ListLineBreakStrategy.java @@ -0,0 +1,27 @@ +package com.g2forge.enigma.document.convert.md.linebreak; + +import com.g2forge.enigma.document.convert.md.IMDRenderContext; +import com.g2forge.enigma.document.model.DocList; +import com.g2forge.enigma.document.model.IDocListItem; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ListLineBreakStrategy implements ILineBreakStrategy { + protected final LineBreakStrategy base; + + @Override + public void beforeItem(IMDRenderContext context, boolean first, IDocListItem item) { + if (!first) { + if (!(item instanceof DocList)) context.append("\\"); + context.newline(); + } + } + + @Override + public void text(IMDRenderContext context, String text) { + context.append(text); + } +} diff --git a/en-document/src/test/java/com/g2forge/enigma/document/convert/md/TestMD.java b/en-document/src/test/java/com/g2forge/enigma/document/convert/md/TestMD.java index bb6c68f..239db26 100644 --- a/en-document/src/test/java/com/g2forge/enigma/document/convert/md/TestMD.java +++ b/en-document/src/test/java/com/g2forge/enigma/document/convert/md/TestMD.java @@ -7,6 +7,7 @@ import com.g2forge.enigma.document.model.Block; import com.g2forge.enigma.document.model.Definition; import com.g2forge.enigma.document.model.DocList; +import com.g2forge.enigma.document.model.DocList.DocListBuilder; import com.g2forge.enigma.document.model.Emphasis; import com.g2forge.enigma.document.model.Text; import com.g2forge.enigma.document.model.Block.BlockBuilder; @@ -14,26 +15,40 @@ public class TestMD { protected final MDRenderer renderer = new MDRenderer(); + protected void assertEquals(final String filename, final Object actual) { + final String expected = HResource.read(getClass(), filename, true); + Assert.assertEquals(expected, renderer.render(actual)); + } + @Test public void help() { final DocList.DocListBuilder builder = DocList.builder().marker(DocList.Marker.Ordered); builder.item(Definition.builder().term(new Emphasis(Emphasis.Type.Code, new Text("-x"))).body(new Text("This is a sentence about the 'x' option!")).build()); builder.item(Definition.builder().term(new Emphasis(Emphasis.Type.Code, new Text("-y"))).body(new Text("Some description of another option")).build()); - final DocList actual = builder.build(); - - final String expected = HResource.read(getClass(), "help.md", true); - Assert.assertEquals(expected, renderer.render(actual)); + assertEquals("help.md", builder.build()); } + @Test + public void lists() { + final BlockBuilder builder = Block.builder().type(Block.Type.Block); + final DocListBuilder list0 = DocList.builder().marker(DocList.Marker.Ordered); + list0.item(Block.builder().type(Block.Type.Block).content(new Text("Item 1 Line 1")).content(new Text("Item 1 Line 2")).build()); + + final DocListBuilder list1 = DocList.builder().marker(DocList.Marker.Ordered); + list1.item(new Text("Item 2A")); + list1.item(new Text("Item 2B")); + list0.item(Block.builder().type(Block.Type.Block).content(new Text("Item 2")).content(list1.build()).build()); + + builder.content(list0.build()); + assertEquals("lists.md", builder.build()); + } + @Test public void simple() { final BlockBuilder builder = Block.builder().type(Block.Type.Block); builder.content(DocList.builder().marker(DocList.Marker.Ordered).item(new Text("Item 1")).build()); builder.content(new Text("A paragraph goes here.")); builder.content(DocList.builder().marker(DocList.Marker.Numbered).item(new Text("Item 2")).item(new Text("Item 3")).build()); - final Block actual = builder.build(); - - final String expected = HResource.read(getClass(), "simple.md", true); - Assert.assertEquals(expected, renderer.render(actual)); + assertEquals("simple.md", builder.build()); } } diff --git a/en-document/src/test/java/com/g2forge/enigma/document/convert/md/lists.md b/en-document/src/test/java/com/g2forge/enigma/document/convert/md/lists.md new file mode 100644 index 0000000..8463336 --- /dev/null +++ b/en-document/src/test/java/com/g2forge/enigma/document/convert/md/lists.md @@ -0,0 +1,5 @@ +* Item 1 Line 1\ +Item 1 Line 2 +* Item 2 + * Item 2A + * Item 2B \ No newline at end of file