Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Contributor 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