Skip to content

Commit 75c6e4c

Browse files
authored
Merge pull request #434 from stupar73/yaml-front-matter-markdown-render
Add support for Markdown rendering for YAML front matter
2 parents 76978a0 + 4ebd40f commit 75c6e4c

5 files changed

Lines changed: 351 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ with the exception that 0.x versions can break between minor versions.
1010
### Added
1111
- Allow customizing HTML attributes for alert title `<p>` tag via `AttributeProvider`
1212
- Support rendering GFM task list items to Markdown
13+
- Support rendering YAML front matter to Markdown
1314

1415
## [0.28.0] - 2026-03-31
1516
### Added

commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterExtension.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package org.commonmark.ext.front.matter;
22

3+
import java.util.Set;
34
import org.commonmark.Extension;
45
import org.commonmark.ext.front.matter.internal.YamlFrontMatterBlockParser;
6+
import org.commonmark.ext.front.matter.internal.YamlFrontMatterMarkdownNodeRenderer;
57
import org.commonmark.parser.Parser;
8+
import org.commonmark.renderer.NodeRenderer;
69
import org.commonmark.renderer.html.HtmlRenderer;
10+
import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
11+
import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory;
12+
import org.commonmark.renderer.markdown.MarkdownRenderer;
713

814
/**
915
* Extension for YAML-like metadata.
@@ -16,7 +22,7 @@
1622
* The parsed metadata is turned into {@link YamlFrontMatterNode}. You can access the metadata using {@link YamlFrontMatterVisitor}.
1723
* </p>
1824
*/
19-
public class YamlFrontMatterExtension implements Parser.ParserExtension {
25+
public class YamlFrontMatterExtension implements Parser.ParserExtension, MarkdownRenderer.MarkdownRendererExtension {
2026

2127
private YamlFrontMatterExtension() {
2228
}
@@ -29,4 +35,19 @@ public void extend(Parser.Builder parserBuilder) {
2935
public static Extension create() {
3036
return new YamlFrontMatterExtension();
3137
}
38+
39+
@Override
40+
public void extend(MarkdownRenderer.Builder rendererBuilder) {
41+
rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() {
42+
@Override
43+
public NodeRenderer create(MarkdownNodeRendererContext context) {
44+
return new YamlFrontMatterMarkdownNodeRenderer(context);
45+
}
46+
47+
@Override
48+
public Set<Character> getSpecialCharacters() {
49+
return Set.of();
50+
}
51+
});
52+
}
3253
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package org.commonmark.ext.front.matter.internal;
2+
3+
import java.util.List;
4+
import org.commonmark.ext.front.matter.YamlFrontMatterNode;
5+
import org.commonmark.node.Node;
6+
import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
7+
import org.commonmark.renderer.markdown.MarkdownWriter;
8+
9+
public class YamlFrontMatterMarkdownNodeRenderer extends YamlFrontMatterNodeRenderer {
10+
11+
private final MarkdownWriter writer;
12+
13+
public YamlFrontMatterMarkdownNodeRenderer(MarkdownNodeRendererContext context) {
14+
this.writer = context.getWriter();
15+
}
16+
17+
@Override
18+
public void render(Node node) {
19+
renderBoundary();
20+
Node child = node.getFirstChild();
21+
while (child != null) {
22+
if (child instanceof YamlFrontMatterNode) {
23+
renderNode((YamlFrontMatterNode) child);
24+
}
25+
child = child.getNext();
26+
}
27+
renderBoundary();
28+
writer.line();
29+
}
30+
31+
private void renderBoundary() {
32+
writer.raw("---");
33+
writer.line();
34+
}
35+
36+
private void renderNode(YamlFrontMatterNode node) {
37+
var values = node.getValues();
38+
if (values.isEmpty()) {
39+
renderEmptyValue(node.getKey());
40+
} else if (values.size() == 1) {
41+
var value = values.get(0);
42+
if (value.contains("\n")) {
43+
renderMultiLineValue(node.getKey(), value.split("\n"));
44+
} else {
45+
renderSingleValue(node.getKey(), value);
46+
}
47+
} else {
48+
renderListValue(node.getKey(), values);
49+
}
50+
}
51+
52+
private void renderEmptyValue(String key) {
53+
writer.raw(key + ":");
54+
writer.line();
55+
}
56+
57+
private void renderSingleValue(String key, String value) {
58+
writer.raw(key + ": " + escapeValue(value));
59+
writer.line();
60+
}
61+
62+
private void renderMultiLineValue(String key, String[] lines) {
63+
writer.raw(key + ": |");
64+
writer.line();
65+
for (var line : lines) {
66+
writer.raw(" " + line);
67+
writer.line();
68+
}
69+
}
70+
71+
private void renderListValue(String key, List<String> values) {
72+
writer.raw(key + ":");
73+
writer.line();
74+
for (var value : values) {
75+
writer.raw(" - " + escapeValue(value));
76+
writer.line();
77+
}
78+
}
79+
80+
private String escapeValue(String value) {
81+
if (needsQuoting(value)) {
82+
return "'" + value.replace("'", "''") + "'";
83+
}
84+
return value;
85+
}
86+
87+
private boolean needsQuoting(String value) {
88+
/*
89+
* NOTE: Deliberately not escaping values which are balanced flow-style arrays/mappings.
90+
* This preserves the round-trip behaviour where these are parsed as a plain string - outputting them as-is will
91+
* result in a valid flow-style array/mapping in the output.
92+
*/
93+
if (isFlowCollection(value)) {
94+
return false;
95+
}
96+
97+
return value.isEmpty()
98+
// Key/value separator
99+
|| value.contains(": ")
100+
// Comment indicator
101+
|| value.contains(" #")
102+
// List indicator
103+
|| value.startsWith("-")
104+
|| value.contains("'")
105+
|| value.contains("\"")
106+
// Unbalanced flow-style list
107+
|| value.startsWith("[")
108+
// Unbalanced flow-style mapping
109+
|| value.startsWith("{");
110+
}
111+
112+
private boolean isFlowCollection(String value) {
113+
return (value.startsWith("[") && value.endsWith("]"))
114+
|| (value.startsWith("{") && value.endsWith("}"));
115+
}
116+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.commonmark.ext.front.matter.internal;
2+
3+
import java.util.Set;
4+
import org.commonmark.ext.front.matter.YamlFrontMatterBlock;
5+
import org.commonmark.node.Node;
6+
import org.commonmark.renderer.NodeRenderer;
7+
8+
abstract class YamlFrontMatterNodeRenderer implements NodeRenderer {
9+
@Override
10+
public Set<Class<? extends Node>> getNodeTypes() {
11+
return Set.of(YamlFrontMatterBlock.class);
12+
}
13+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package org.commonmark.ext.front.matter;
2+
3+
import org.commonmark.Extension;
4+
import org.commonmark.node.Document;
5+
import org.commonmark.node.Node;
6+
import org.commonmark.node.Paragraph;
7+
import org.commonmark.node.Text;
8+
import org.commonmark.parser.Parser;
9+
import org.commonmark.renderer.markdown.MarkdownRenderer;
10+
import org.junit.jupiter.api.Test;
11+
12+
import java.util.List;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
16+
public class YamlFrontMatterMarkdownRendererTest {
17+
18+
private static final List<Extension> EXTENSIONS = List.of(YamlFrontMatterExtension.create());
19+
private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build();
20+
private static final MarkdownRenderer RENDERER = MarkdownRenderer.builder().extensions(EXTENSIONS).build();
21+
22+
// ===== Round-trip tests (parse string -> render -> compare to input) =====
23+
24+
@Test
25+
public void testRoundTripSimple() {
26+
assertRoundTrip("---\ntitle: My Document\n---\n\nMarkdown content\n");
27+
}
28+
29+
@Test
30+
public void testRoundTripEmptyValue() {
31+
assertRoundTrip("---\nkey:\n---\n\nMarkdown content\n");
32+
}
33+
34+
@Test
35+
public void testRoundTripMultipleKeys() {
36+
assertRoundTrip("---\ntitle: My Document\nauthor: John Doe\n---\n\nMarkdown content\n");
37+
}
38+
39+
@Test
40+
public void testRoundTripListValues() {
41+
assertRoundTrip("---\ntags:\n - java\n - markdown\n---\n\nMarkdown content\n");
42+
}
43+
44+
@Test
45+
public void testRoundTripLiteralBlock() {
46+
assertRoundTrip("---\ndescription: |\n first line\n second line\n---\n\nMarkdown content\n");
47+
}
48+
49+
@Test
50+
public void testRoundTripSingleQuotedValue() {
51+
assertRoundTrip("---\nkey: 'value with ''single quotes'''\n---\n\nMarkdown content\n");
52+
}
53+
54+
@Test
55+
public void testRoundTripDoubleQuotedValue() {
56+
/*
57+
* NOTE: We don't know what the original escape character was and the markdown renderer always uses single
58+
* quote, hence why this technically doesn't round-trip.
59+
*/
60+
var input = "---\nkey: \"value with \\\"double quotes\\\"\"\n---\n\nMarkdown content\n";
61+
var rendered = RENDERER.render(PARSER.parse(input));
62+
var expected = "---\nkey: 'value with \"double quotes\"'\n---\n\nMarkdown content\n";
63+
assertThat(rendered).isEqualTo(expected);
64+
}
65+
66+
@Test
67+
public void testRoundTripFlowList() {
68+
// Flow-style list is stored as a single value - "[java, markdown]" - rendered back unquoted
69+
assertRoundTrip("---\ntags: [java, markdown]\n---\n\nMarkdown content\n");
70+
}
71+
72+
@Test
73+
public void testRoundTripFlowMapping() {
74+
// Flow-style mapping is stored as a single value - "{key: value}" - rendered back unquoted
75+
assertRoundTrip("---\ndata: {key: value}\n---\n\nMarkdown content\n");
76+
}
77+
78+
@Test
79+
public void testRoundTripEmptyFrontmatter() {
80+
assertRoundTrip("---\n---\n\nMarkdown content\n");
81+
}
82+
83+
// ===== Programmatic construction tests =====
84+
85+
@Test
86+
public void testProgrammaticallyBuilt() {
87+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("title", List.of("My Document"))));
88+
89+
assertRenderedEquals(doc, "---\ntitle: My Document\n---\n\nMarkdown content\n");
90+
}
91+
92+
// ===== Quoting tests (values needing special treatment) =====
93+
94+
@Test
95+
public void testValueWithColonSpace() {
96+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("value with a: colon inside"))));
97+
98+
assertRenderedEquals(doc, "---\nkey: 'value with a: colon inside'\n---\n\nMarkdown content\n");
99+
}
100+
101+
@Test
102+
public void testValueWithColonNoSpace() {
103+
// Colon without trailing space is fine unquoted (e.g. timestamps, URLs)
104+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("time", List.of("12:30:00"))));
105+
106+
assertRenderedEquals(doc, "---\ntime: 12:30:00\n---\n\nMarkdown content\n");
107+
}
108+
109+
@Test
110+
public void testValueStartingWithDash() {
111+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("- not a list"))));
112+
113+
assertRenderedEquals(doc, "---\nkey: '- not a list'\n---\n\nMarkdown content\n");
114+
}
115+
116+
@Test
117+
public void testValueStartingWithUnmatchedBracket() {
118+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("[broken"))));
119+
120+
assertRenderedEquals(doc, "---\nkey: '[broken'\n---\n\nMarkdown content\n");
121+
}
122+
123+
@Test
124+
public void testValueStartingWithMatchedBrackets() {
125+
// Valid flow list - should NOT be quoted
126+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("flowList", List.of("[1, 2, 3]"))));
127+
128+
assertRenderedEquals(doc, "---\nflowList: [1, 2, 3]\n---\n\nMarkdown content\n");
129+
}
130+
131+
@Test
132+
public void testValueStartingWithUnmatchedBrace() {
133+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("{broken"))));
134+
135+
assertRenderedEquals(doc, "---\nkey: '{broken'\n---\n\nMarkdown content\n");
136+
}
137+
138+
@Test
139+
public void testValueStartingWithMatchedBraces() {
140+
// Valid flow mapping - should NOT be quoted
141+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("flowMapping", List.of("{key: val}"))));
142+
143+
assertRenderedEquals(doc, "---\nflowMapping: {key: val}\n---\n\nMarkdown content\n");
144+
}
145+
146+
@Test
147+
public void testValueContainingHashComment() {
148+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("value # not a comment"))));
149+
150+
assertRenderedEquals(doc, "---\nkey: 'value # not a comment'\n---\n\nMarkdown content\n");
151+
}
152+
153+
@Test
154+
public void testValueContainingApostrophe() {
155+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("it's a test"))));
156+
157+
assertRenderedEquals(doc, "---\nkey: 'it''s a test'\n---\n\nMarkdown content\n");
158+
}
159+
160+
@Test
161+
public void testEmptyStringValue() {
162+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("empty", List.of(""))));
163+
164+
assertRenderedEquals(doc, "---\nempty: ''\n---\n\nMarkdown content\n");
165+
}
166+
167+
@Test
168+
public void testValueStartingWithDoubleQuote() {
169+
var doc = buildDocumentWithFrontMatter(List.of(new YamlFrontMatterNode("key", List.of("\"quotes within value\""))));
170+
171+
assertRenderedEquals(doc, "---\nkey: '\"quotes within value\"'\n---\n\nMarkdown content\n");
172+
}
173+
174+
private void assertRoundTrip(String input) {
175+
String rendered = RENDERER.render(PARSER.parse(input));
176+
assertThat(rendered).isEqualTo(input);
177+
}
178+
179+
private void assertRenderedEquals(Node inputNode, String expectedOutput) {
180+
var renderedOutput = RENDERER.render(inputNode);
181+
assertThat(renderedOutput).isEqualTo(expectedOutput);
182+
}
183+
184+
private Document buildDocumentWithFrontMatter(List<YamlFrontMatterNode> frontMatterNodes) {
185+
var doc = new Document();
186+
187+
var frontmatter = new YamlFrontMatterBlock();
188+
for (var frontMatterNode : frontMatterNodes) {
189+
frontmatter.appendChild(frontMatterNode);
190+
}
191+
doc.appendChild(frontmatter);
192+
193+
var para = new Paragraph();
194+
para.appendChild(new Text("Markdown content"));
195+
doc.appendChild(para);
196+
197+
return doc;
198+
}
199+
}

0 commit comments

Comments
 (0)