diff --git a/README.md b/README.md index 8967643e4..c0347d34f 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,26 @@ Enables tables using pipes as in [GitHub Flavored Markdown][gfm-tables]. Use class `TablesExtension` in artifact `commonmark-ext-gfm-tables`. +### YAML front matter + +Enables an YAML front matter block. This extension only supports a subset of YAML syntax. Here's an example of what's supported: + +``` +--- +key: value +list: + - value 1 + - value 2 +literal: | + this is literal value. + + literal values 2 +--- + +document start here +``` + +Use class `YamlFrontMatterExtension` in artifact `commonmark-ext-yaml-front-matter`. To fetch metadata, use `YamlFrontMatterVisitor`. Contributing ------------ diff --git a/commonmark-ext-yaml-front-matter/pom.xml b/commonmark-ext-yaml-front-matter/pom.xml new file mode 100644 index 000000000..ada9956bc --- /dev/null +++ b/commonmark-ext-yaml-front-matter/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + commonmark-parent + com.atlassian.commonmark + 0.4.2-SNAPSHOT + + + commonmark-ext-yaml-front-matter + commonmark-java extension for YAML front matter + commonmark-java extension for YAML front matter + + + + com.atlassian.commonmark + commonmark + + + + com.atlassian.commonmark + commonmark-test-util + test + + + + diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterBlock.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterBlock.java new file mode 100644 index 000000000..0d9aba2d3 --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterBlock.java @@ -0,0 +1,6 @@ +package org.commonmark.ext.front.matter; + +import org.commonmark.node.CustomBlock; + +public class YamlFrontMatterBlock extends CustomBlock { +} 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 new file mode 100644 index 000000000..ec01b3194 --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterExtension.java @@ -0,0 +1,37 @@ +package org.commonmark.ext.front.matter; + +import org.commonmark.Extension; +import org.commonmark.ext.front.matter.internal.YamlFrontMatterBlockParser; +import org.commonmark.ext.front.matter.internal.YamlFrontMatterBlockRenderer; +import org.commonmark.html.HtmlRenderer; +import org.commonmark.parser.Parser; + +/** + * Extension for YAML-like metadata. + *

+ * Create it with {@link #create()} and then configure it on the builders + * ({@link org.commonmark.parser.Parser.Builder#extensions(Iterable)}, + * {@link org.commonmark.html.HtmlRenderer.Builder#extensions(Iterable)}). + *

+ *

+ * The parsed metadata is turned into {@link YamlFrontMatterNode}. You can access the metadata using {@link YamlFrontMatterVisitor}. + *

+ */ +public class YamlFrontMatterExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension { + private YamlFrontMatterExtension() { + } + + @Override + public void extend(HtmlRenderer.Builder rendererBuilder) { + rendererBuilder.customHtmlRenderer(new YamlFrontMatterBlockRenderer()); + } + + @Override + public void extend(Parser.Builder parserBuilder) { + parserBuilder.customBlockParserFactory(new YamlFrontMatterBlockParser.Factory()); + } + + public static Extension create() { + return new YamlFrontMatterExtension(); + } +} diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterNode.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterNode.java new file mode 100644 index 000000000..20eb3baf7 --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterNode.java @@ -0,0 +1,31 @@ +package org.commonmark.ext.front.matter; + +import org.commonmark.node.CustomNode; + +import java.util.List; + +public class YamlFrontMatterNode extends CustomNode { + private String key; + private List values; + + public YamlFrontMatterNode(String key, List values) { + this.key = key; + this.values = values; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public List getValues() { + return values; + } + + public void setValues(List values) { + this.values = values; + } +} diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterVisitor.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterVisitor.java new file mode 100644 index 000000000..1c23966f5 --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/YamlFrontMatterVisitor.java @@ -0,0 +1,29 @@ +package org.commonmark.ext.front.matter; + +import org.commonmark.node.AbstractVisitor; +import org.commonmark.node.CustomNode; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class YamlFrontMatterVisitor extends AbstractVisitor { + private Map> data; + + public YamlFrontMatterVisitor() { + data = new LinkedHashMap<>(); + } + + @Override + public void visit(CustomNode customNode) { + if (customNode instanceof YamlFrontMatterNode) { + data.put(((YamlFrontMatterNode) customNode).getKey(), ((YamlFrontMatterNode) customNode).getValues()); + } else { + super.visit(customNode); + } + } + + public Map> getData() { + return data; + } +} diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterBlockParser.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterBlockParser.java new file mode 100644 index 000000000..4de4ae12f --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterBlockParser.java @@ -0,0 +1,118 @@ +package org.commonmark.ext.front.matter.internal; + +import org.commonmark.ext.front.matter.YamlFrontMatterBlock; +import org.commonmark.ext.front.matter.YamlFrontMatterNode; +import org.commonmark.internal.DocumentBlockParser; +import org.commonmark.node.Block; +import org.commonmark.parser.InlineParser; +import org.commonmark.parser.block.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class YamlFrontMatterBlockParser extends AbstractBlockParser { + private static final Pattern REGEX_METADATA = Pattern.compile("^[ ]{0,3}([A-Za-z0-9_-]+):\\s*(.*)"); + private static final Pattern REGEX_METADATA_LIST = Pattern.compile("^[ ]+-\\s*(.*)"); + private static final Pattern REGEX_METADATA_LITERAL = Pattern.compile("^\\s*(.*)"); + private static final Pattern REGEX_BEGIN = Pattern.compile("^-{3}(\\s.*)?"); + private static final Pattern REGEX_END = Pattern.compile("^(-{3}|\\.{3})(\\s.*)?"); + + private boolean inYAMLBlock; + private boolean inLiteral; + private String currentKey; + private List currentValues; + private YamlFrontMatterBlock block; + + public YamlFrontMatterBlockParser() { + inYAMLBlock = true; + inLiteral = false; + currentKey = null; + currentValues = new ArrayList<>(); + block = new YamlFrontMatterBlock(); + } + + @Override + public Block getBlock() { + return block; + } + + @Override + public void addLine(CharSequence line) { + } + + @Override + public BlockContinue tryContinue(ParserState parserState) { + final CharSequence line = parserState.getLine(); + + if (inYAMLBlock) { + if (REGEX_END.matcher(line).matches()) { + if (currentKey != null) { + block.appendChild(new YamlFrontMatterNode(currentKey, currentValues)); + } + return BlockContinue.finished(); + } + + Matcher matcher = REGEX_METADATA.matcher(line); + if (matcher.matches()) { + if (currentKey != null) { + block.appendChild(new YamlFrontMatterNode(currentKey, currentValues)); + } + + inLiteral = false; + currentKey = matcher.group(1); + currentValues = new ArrayList<>(); + if ("|".equals(matcher.group(2))) { + inLiteral = true; + } else if (!"".equals(matcher.group(2))) { + currentValues.add(matcher.group(2)); + } + + return BlockContinue.atIndex(parserState.getIndex()); + } else { + if (inLiteral) { + matcher = REGEX_METADATA_LITERAL.matcher(line); + if (matcher.matches()) { + if (currentValues.size() == 1) { + currentValues.set(0, currentValues.get(0) + "\n" + matcher.group(1).trim()); + } else { + currentValues.add(matcher.group(1).trim()); + } + } + } else { + matcher = REGEX_METADATA_LIST.matcher(line); + if (matcher.matches()) { + currentValues.add(matcher.group(1)); + } + } + + return BlockContinue.atIndex(parserState.getIndex()); + } + } else if (REGEX_BEGIN.matcher(line).matches()) { + inYAMLBlock = true; + return BlockContinue.atIndex(parserState.getIndex()); + } + + return BlockContinue.none(); + } + + @Override + public void parseInlines(InlineParser inlineParser) { + } + + public static class Factory extends AbstractBlockParserFactory { + @Override + public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { + CharSequence line = state.getLine(); + BlockParser parentParser = matchedBlockParser.getMatchedBlockParser(); + // check whether this line is the first line of whole document or not + if (parentParser instanceof DocumentBlockParser && parentParser.getBlock().getFirstChild() == null && + REGEX_BEGIN.matcher(line).matches()) { + return BlockStart.of(new YamlFrontMatterBlockParser()).atIndex(state.getNextNonSpaceIndex()); + } + + return BlockStart.none(); + } + } +} diff --git a/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterBlockRenderer.java b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterBlockRenderer.java new file mode 100644 index 000000000..436401194 --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/java/org/commonmark/ext/front/matter/internal/YamlFrontMatterBlockRenderer.java @@ -0,0 +1,15 @@ +package org.commonmark.ext.front.matter.internal; + +import org.commonmark.ext.front.matter.YamlFrontMatterBlock; +import org.commonmark.ext.front.matter.YamlFrontMatterNode; +import org.commonmark.html.CustomHtmlRenderer; +import org.commonmark.html.HtmlWriter; +import org.commonmark.node.Node; +import org.commonmark.node.Visitor; + +public class YamlFrontMatterBlockRenderer implements CustomHtmlRenderer { + @Override + public boolean render(Node node, HtmlWriter htmlWriter, Visitor visitor) { + return node instanceof YamlFrontMatterBlock || node instanceof YamlFrontMatterNode; + } +} diff --git a/commonmark-ext-yaml-front-matter/src/main/javadoc/overview.html b/commonmark-ext-yaml-front-matter/src/main/javadoc/overview.html new file mode 100644 index 000000000..d5ef92fdc --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/main/javadoc/overview.html @@ -0,0 +1,6 @@ + + +Extension for YAML front matter +

See {@link org.commonmark.ext.yaml.YamlFrontMatterExtension}

+ + diff --git a/commonmark-ext-yaml-front-matter/src/test/java/org/commonmark/ext/front/matter/YamlFrontMatterTest.java b/commonmark-ext-yaml-front-matter/src/test/java/org/commonmark/ext/front/matter/YamlFrontMatterTest.java new file mode 100644 index 000000000..619d44be8 --- /dev/null +++ b/commonmark-ext-yaml-front-matter/src/test/java/org/commonmark/ext/front/matter/YamlFrontMatterTest.java @@ -0,0 +1,223 @@ +package org.commonmark.ext.front.matter; + +import org.commonmark.Extension; +import org.commonmark.html.HtmlRenderer; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.test.RenderingTestCase; +import org.junit.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class YamlFrontMatterTest extends RenderingTestCase { + private static final Set EXTENSIONS = Collections.singleton(YamlFrontMatterExtension.create()); + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + private static final HtmlRenderer RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).build(); + + @Test + public void simpleValue() { + final String input = "---" + + "\nhello: world" + + "\n..." + + "\n" + + "\ngreat"; + final String rendered = "

great

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertEquals(1, data.size()); + assertEquals("hello", data.keySet().iterator().next()); + assertEquals(1, data.get("hello").size()); + assertEquals("world", data.get("hello").get(0)); + + assertRendering(input, rendered); + } + + @Test + public void emptyValue() { + final String input = "---" + + "\nkey:" + + "\n---" + + "\n" + + "\ngreat"; + final String rendered = "

great

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertEquals(1, data.size()); + assertEquals("key", data.keySet().iterator().next()); + assertEquals(0, data.get("key").size()); + + assertRendering(input, rendered); + } + + @Test + public void listValues() { + final String input = "---" + + "\nlist:" + + "\n - value1" + + "\n - value2" + + "\n..." + + "\n" + + "\ngreat"; + final String rendered = "

great

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertEquals(1, data.size()); + assertTrue(data.containsKey("list")); + assertEquals(2, data.get("list").size()); + assertEquals("value1", data.get("list").get(0)); + assertEquals("value2", data.get("list").get(1)); + + assertRendering(input, rendered); + } + + @Test + public void literalValue1() { + final String input = "---" + + "\nliteral: |" + + "\n hello markdown!" + + "\n literal thing..." + + "\n---" + + "\n" + + "\ngreat"; + final String rendered = "

great

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertEquals(1, data.size()); + assertTrue(data.containsKey("literal")); + assertEquals(1, data.get("literal").size()); + assertEquals("hello markdown!\nliteral thing...", data.get("literal").get(0)); + + assertRendering(input, rendered); + } + + @Test + public void literalValue2() { + final String input = "---" + + "\nliteral: |" + + "\n - hello markdown!" + + "\n---" + + "\n" + + "\ngreat"; + final String rendered = "

great

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertEquals(1, data.size()); + assertTrue(data.containsKey("literal")); + assertEquals(1, data.get("literal").size()); + assertEquals("- hello markdown!", data.get("literal").get(0)); + + assertRendering(input, rendered); + } + + @Test + public void complexValues() { + final String input = "---" + + "\nsimple: value" + + "\nliteral: |" + + "\n hello markdown!" + + "\n" + + "\n literal literal" + + "\nlist:" + + "\n - value1" + + "\n - value2" + + "\n---" + + "\ngreat"; + final String rendered = "

great

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertEquals(3, data.size()); + + assertTrue(data.containsKey("simple")); + assertEquals(1, data.get("simple").size()); + assertEquals("value", data.get("simple").get(0)); + + assertTrue(data.containsKey("literal")); + assertEquals(1, data.get("literal").size()); + assertEquals("hello markdown!\n\nliteral literal", data.get("literal").get(0)); + + assertTrue(data.containsKey("list")); + assertEquals(2, data.get("list").size()); + assertEquals("value1", data.get("list").get(0)); + assertEquals("value2", data.get("list").get(1)); + + assertRendering(input, rendered); + } + + @Test + public void yamlInParagraph() { + final String input = "# hello\n" + + "\nhello markdown world!" + + "\n---" + + "\nhello: world" + + "\n---"; + final String rendered = "

hello

\n

hello markdown world!

\n

hello: world

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertTrue(data.isEmpty()); + + assertRendering(input, rendered); + } + + @Test + public void nonMatchedStartTag() { + final String input = "----\n" + + "test"; + final String rendered = "
\n

test

\n"; + + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + Node document = PARSER.parse(input); + document.accept(visitor); + + Map> data = visitor.getData(); + + assertTrue(data.isEmpty()); + + assertRendering(input, rendered); + } + + @Override + protected String render(String source) { + return RENDERER.render(PARSER.parse(source)); + } +} diff --git a/commonmark-integration-test/pom.xml b/commonmark-integration-test/pom.xml index 784a2d026..7e985e7b1 100644 --- a/commonmark-integration-test/pom.xml +++ b/commonmark-integration-test/pom.xml @@ -32,6 +32,11 @@ commonmark-ext-gfm-tables test + + com.atlassian.commonmark + commonmark-ext-yaml-front-matter + test + org.pegdown pegdown diff --git a/commonmark-integration-test/src/test/java/org/commonmark/integration/SpecIntegrationTest.java b/commonmark-integration-test/src/test/java/org/commonmark/integration/SpecIntegrationTest.java index be5f9d890..a45e0375d 100644 --- a/commonmark-integration-test/src/test/java/org/commonmark/integration/SpecIntegrationTest.java +++ b/commonmark-integration-test/src/test/java/org/commonmark/integration/SpecIntegrationTest.java @@ -4,6 +4,7 @@ import org.commonmark.ext.autolink.AutolinkExtension; import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.ext.front.matter.YamlFrontMatterExtension; import org.commonmark.html.HtmlRenderer; import org.commonmark.parser.Parser; import org.commonmark.spec.SpecExample; @@ -20,7 +21,8 @@ public class SpecIntegrationTest extends SpecTestCase { private static final List EXTENSIONS = Arrays.asList( AutolinkExtension.create(), StrikethroughExtension.create(), - TablesExtension.create()); + TablesExtension.create(), + YamlFrontMatterExtension.create()); private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); // The spec says URL-escaping is optional, but the examples assume that it's enabled. private static final HtmlRenderer RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).percentEncodeUrls(true).build(); @@ -67,6 +69,10 @@ private static Map getOverriddenExamples() { // Plain autolink m.put("foo@bar.example.com\n", "

foo@bar.example.com

\n"); + // YAML front matter block + m.put("---\nFoo\n---\nBar\n---\nBaz\n", "

Bar

\n

Baz

\n"); + m.put("---\n---\n", ""); + return m; } diff --git a/pom.xml b/pom.xml index 1d5ee2c14..de78615b7 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ commonmark-ext-autolink commonmark-ext-gfm-strikethrough commonmark-ext-gfm-tables + commonmark-ext-yaml-front-matter commonmark-integration-test commonmark-test-util @@ -100,6 +101,11 @@ commonmark-ext-gfm-tables 0.4.2-SNAPSHOT
+ + com.atlassian.commonmark + commonmark-ext-yaml-front-matter + 0.4.2-SNAPSHOT + com.atlassian.commonmark commonmark-test-util