From 6f4673d4c47f56ff1e54e7db42cc92fafb44ec09 Mon Sep 17 00:00:00 2001
From: Stuart Parker <2979257+stupar73@users.noreply.github.com>
Date: Mon, 11 May 2026 14:13:53 +1000
Subject: [PATCH 1/3] Add support for Markdown rendering for YAML front matter
---
.../matter/YamlFrontMatterExtension.java | 23 +++-
.../YamlFrontMatterMarkdownNodeRenderer.java | 116 ++++++++++++++++++
.../internal/YamlFrontMatterNodeRenderer.java | 13 ++
3 files changed, 151 insertions(+), 1 deletion(-)
create mode 100644 commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterMarkdownNodeRenderer.java
create mode 100644 commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterNodeRenderer.java
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 7a2c9f9f5..99ca17708 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 000000000..355596c8e
--- /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 000000000..607b621ec
--- /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);
+ }
+}
From 85dd241553108070aeb6327fbeed413db02fb78e Mon Sep 17 00:00:00 2001
From: Stuart Parker <2979257+stupar73@users.noreply.github.com>
Date: Mon, 11 May 2026 14:13:54 +1000
Subject: [PATCH 2/3] Add tests
---
.../YamlFrontMatterMarkdownRendererTest.java | 199 ++++++++++++++++++
1 file changed, 199 insertions(+)
create mode 100644 commonmark-ext-yaml-front-matter/src/test/java/org/commonmark/ext/front/matter/YamlFrontMatterMarkdownRendererTest.java
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 000000000..987513ea4
--- /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;
+ }
+}
From 79864a4aa59f3bc9d8108541f1cc98784a2575b8 Mon Sep 17 00:00:00 2001
From: Stuart Parker <2979257+stupar73@users.noreply.github.com>
Date: Mon, 11 May 2026 14:13:54 +1000
Subject: [PATCH 3/3] Add changelog entry
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c5c67268..e7eb1ab39 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