diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5c6726..e7eb1ab3 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 YAML front matter to Markdown ## [0.28.0] - 2026-03-31 ### Added diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterExtension.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterExtension.java index 7a2c9f9f..99ca1770 100644 --- a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterExtension.java +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterExtension.java @@ -1,9 +1,15 @@ package org.commonmark.ext.front.matter; +import java.util.Set; import org.commonmark.Extension; import org.commonmark.ext.front.matter.internal.YamlFrontMatterBlockParser; +import org.commonmark.ext.front.matter.internal.YamlFrontMatterMarkdownNodeRenderer; 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 YAML-like metadata. @@ -16,7 +22,7 @@ * The parsed metadata is turned into {@link YamlFrontMatterNode}. You can access the metadata using {@link YamlFrontMatterVisitor}. *

*/ -public class YamlFrontMatterExtension implements Parser.ParserExtension { +public class YamlFrontMatterExtension implements Parser.ParserExtension, MarkdownRenderer.MarkdownRendererExtension { private YamlFrontMatterExtension() { } @@ -29,4 +35,19 @@ public void extend(Parser.Builder parserBuilder) { public static Extension create() { return new YamlFrontMatterExtension(); } + + @Override + public void extend(MarkdownRenderer.Builder rendererBuilder) { + rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() { + @Override + public NodeRenderer create(MarkdownNodeRendererContext context) { + return new YamlFrontMatterMarkdownNodeRenderer(context); + } + + @Override + public Set getSpecialCharacters() { + return Set.of(); + } + }); + } } diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterMarkdownNodeRenderer.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterMarkdownNodeRenderer.java new file mode 100644 index 00000000..355596c8 --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterMarkdownNodeRenderer.java @@ -0,0 +1,116 @@ +package org.commonmark.ext.front.matter.internal; + +import java.util.List; +import org.commonmark.ext.front.matter.YamlFrontMatterNode; +import org.commonmark.node.Node; +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownWriter; + +public class YamlFrontMatterMarkdownNodeRenderer extends YamlFrontMatterNodeRenderer { + + private final MarkdownWriter writer; + + public YamlFrontMatterMarkdownNodeRenderer(MarkdownNodeRendererContext context) { + this.writer = context.getWriter(); + } + + @Override + public void render(Node node) { + renderBoundary(); + Node child = node.getFirstChild(); + while (child != null) { + if (child instanceof YamlFrontMatterNode) { + renderNode((YamlFrontMatterNode) child); + } + child = child.getNext(); + } + renderBoundary(); + writer.line(); + } + + private void renderBoundary() { + writer.raw("---"); + writer.line(); + } + + private void renderNode(YamlFrontMatterNode node) { + var values = node.getValues(); + if (values.isEmpty()) { + renderEmptyValue(node.getKey()); + } else if (values.size() == 1) { + var value = values.get(0); + if (value.contains("\n")) { + renderMultiLineValue(node.getKey(), value.split("\n")); + } else { + renderSingleValue(node.getKey(), value); + } + } else { + renderListValue(node.getKey(), values); + } + } + + private void renderEmptyValue(String key) { + writer.raw(key + ":"); + writer.line(); + } + + private void renderSingleValue(String key, String value) { + writer.raw(key + ": " + escapeValue(value)); + writer.line(); + } + + private void renderMultiLineValue(String key, String[] lines) { + writer.raw(key + ": |"); + writer.line(); + for (var line : lines) { + writer.raw(" " + line); + writer.line(); + } + } + + private void renderListValue(String key, List values) { + writer.raw(key + ":"); + writer.line(); + for (var value : values) { + writer.raw(" - " + escapeValue(value)); + writer.line(); + } + } + + private String escapeValue(String value) { + if (needsQuoting(value)) { + return "'" + value.replace("'", "''") + "'"; + } + return value; + } + + private boolean needsQuoting(String value) { + /* + * NOTE: Deliberately not escaping values which are balanced flow-style arrays/mappings. + * This preserves the round-trip behaviour where these are parsed as a plain string - outputting them as-is will + * result in a valid flow-style array/mapping in the output. + */ + if (isFlowCollection(value)) { + return false; + } + + return value.isEmpty() + // Key/value separator + || value.contains(": ") + // Comment indicator + || value.contains(" #") + // List indicator + || value.startsWith("-") + || value.contains("'") + || value.contains("\"") + // Unbalanced flow-style list + || value.startsWith("[") + // Unbalanced flow-style mapping + || value.startsWith("{"); + } + + private boolean isFlowCollection(String value) { + return (value.startsWith("[") && value.endsWith("]")) + || (value.startsWith("{") && value.endsWith("}")); + } +} diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterNodeRenderer.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterNodeRenderer.java new file mode 100644 index 00000000..607b621e --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterNodeRenderer.java @@ -0,0 +1,13 @@ +package org.commonmark.ext.front.matter.internal; + +import java.util.Set; +import org.commonmark.ext.front.matter.YamlFrontMatterBlock; +import org.commonmark.node.Node; +import org.commonmark.renderer.NodeRenderer; + +abstract class YamlFrontMatterNodeRenderer implements NodeRenderer { + @Override + public Set> getNodeTypes() { + return Set.of(YamlFrontMatterBlock.class); + } +} diff --git a/commonmark-ext-yaml-front-matter/src/test/java/org/commonmark/ext/front/matter/YamlFrontMatterMarkdownRendererTest.java b/commonmark-ext-yaml-front-matter/src/test/java/org/commonmark/ext/front/matter/YamlFrontMatterMarkdownRendererTest.java new file mode 100644 index 00000000..987513ea --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/test/java/org/commonmark/ext/front/matter/YamlFrontMatterMarkdownRendererTest.java @@ -0,0 +1,199 @@ +package org.commonmark.ext.front.matter; + +import org.commonmark.Extension; +import org.commonmark.node.Document; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class YamlFrontMatterMarkdownRendererTest { + + private static final List EXTENSIONS = List.of(YamlFrontMatterExtension.create()); + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + private static final MarkdownRenderer RENDERER = MarkdownRenderer.builder().extensions(EXTENSIONS).build(); + + // ===== Round-trip tests (parse string -> render -> compare to input) ===== + + @Test + public void testRoundTripSimple() { + assertRoundTrip("---\ntitle: My Document\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripEmptyValue() { + assertRoundTrip("---\nkey:\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripMultipleKeys() { + assertRoundTrip("---\ntitle: My Document\nauthor: John Doe\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripListValues() { + assertRoundTrip("---\ntags:\n - java\n - markdown\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripLiteralBlock() { + assertRoundTrip("---\ndescription: |\n first line\n second line\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripSingleQuotedValue() { + assertRoundTrip("---\nkey: 'value with ''single quotes'''\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripDoubleQuotedValue() { + /* + * NOTE: We don't know what the original escape character was and the markdown renderer always uses single + * quote, hence why this technically doesn't round-trip. + */ + var input = "---\nkey: \"value with \\\"double quotes\\\"\"\n---\n\nMarkdown content\n"; + var rendered = RENDERER.render(PARSER.parse(input)); + var expected = "---\nkey: 'value with \"double quotes\"'\n---\n\nMarkdown content\n"; + assertThat(rendered).isEqualTo(expected); + } + + @Test + public void testRoundTripFlowList() { + // Flow-style list is stored as a single value - "[java, markdown]" - rendered back unquoted + assertRoundTrip("---\ntags: [java, markdown]\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripFlowMapping() { + // Flow-style mapping is stored as a single value - "{key: value}" - rendered back unquoted + assertRoundTrip("---\ndata: {key: value}\n---\n\nMarkdown content\n"); + } + + @Test + public void testRoundTripEmptyFrontmatter() { + assertRoundTrip("---\n---\n\nMarkdown content\n"); + } + + // ===== Programmatic construction tests ===== + + @Test + public void testProgrammaticallyBuilt() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("title", List.of("My Document")))); + + assertRenderedEquals(doc, "---\ntitle: My Document\n---\n\nMarkdown content\n"); + } + + // ===== Quoting tests (values needing special treatment) ===== + + @Test + public void testValueWithColonSpace() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("value with a: colon inside")))); + + assertRenderedEquals(doc, "---\nkey: 'value with a: colon inside'\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueWithColonNoSpace() { + // Colon without trailing space is fine unquoted (e.g. timestamps, URLs) + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("time", List.of("12:30:00")))); + + assertRenderedEquals(doc, "---\ntime: 12:30:00\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueStartingWithDash() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("- not a list")))); + + assertRenderedEquals(doc, "---\nkey: '- not a list'\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueStartingWithUnmatchedBracket() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("[broken")))); + + assertRenderedEquals(doc, "---\nkey: '[broken'\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueStartingWithMatchedBrackets() { + // Valid flow list - should NOT be quoted + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("flowList", List.of("[1, 2, 3]")))); + + assertRenderedEquals(doc, "---\nflowList: [1, 2, 3]\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueStartingWithUnmatchedBrace() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("{broken")))); + + assertRenderedEquals(doc, "---\nkey: '{broken'\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueStartingWithMatchedBraces() { + // Valid flow mapping - should NOT be quoted + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("flowMapping", List.of("{key: val}")))); + + assertRenderedEquals(doc, "---\nflowMapping: {key: val}\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueContainingHashComment() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("value # not a comment")))); + + assertRenderedEquals(doc, "---\nkey: 'value # not a comment'\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueContainingApostrophe() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("it's a test")))); + + assertRenderedEquals(doc, "---\nkey: 'it''s a test'\n---\n\nMarkdown content\n"); + } + + @Test + public void testEmptyStringValue() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("empty", List.of("")))); + + assertRenderedEquals(doc, "---\nempty: ''\n---\n\nMarkdown content\n"); + } + + @Test + public void testValueStartingWithDoubleQuote() { + var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("\"quotes within value\"")))); + + assertRenderedEquals(doc, "---\nkey: '\"quotes within value\"'\n---\n\nMarkdown content\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); + } + + private Document buildDocumentWithFrontMatter(List frontMatterNodes) { + var doc = new Document(); + + var frontmatter = new YamlFrontMatterBlock(); + for (var frontMatterNode : frontMatterNodes) { + frontmatter.appendChild(frontMatterNode); + } + doc.appendChild(frontmatter); + + var para = new Paragraph(); + para.appendChild(new Text("Markdown content")); + doc.appendChild(para); + + return doc; + } +}