Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p>` tag via `AttributeProvider`
- Support rendering YAML front matter to Markdown

## [0.28.0] - 2026-03-31
### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,7 +22,7 @@
* The parsed metadata is turned into {@link YamlFrontMatterNode}. You can access the metadata using {@link YamlFrontMatterVisitor}.
* </p>
*/
public class YamlFrontMatterExtension implements Parser.ParserExtension {
public class YamlFrontMatterExtension implements Parser.ParserExtension, MarkdownRenderer.MarkdownRendererExtension {

private YamlFrontMatterExtension() {
}
Expand All @@ -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<Character> getSpecialCharacters() {
return Set.of();
}
});
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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("}"));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this special handling is a bit unfortunate, we should probably never have started trying to parse the YAML ourselves (but treat it as an opaque blob instead). See also this issue:

Having said that, it is what we currently do so what you have here LGTM.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah agreed, it feels a bit like it shouldn't be this libraries responsibility to handle the content of the YAML front matter.

But as you say, that's what we currently do, and I think this is an net positive from the current state.

}
}
Original file line number Diff line number Diff line change
@@ -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<Class<? extends Node>> getNodeTypes() {
return Set.of(YamlFrontMatterBlock.class);
}
}
Original file line number Diff line number Diff line change
@@ -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<Extension> 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<YamlFrontMatterNode> 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;
}
}
Loading