diff --git a/commonmark-ext-gfm-alerts/README.md b/commonmark-ext-gfm-alerts/README.md new file mode 100644 index 00000000..b70584e9 --- /dev/null +++ b/commonmark-ext-gfm-alerts/README.md @@ -0,0 +1,70 @@ +# commonmark-ext-gfm-alerts + +Extension for [commonmark-java](https://github.com/commonmark/commonmark-java) that adds support for [GitHub Flavored Markdown alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts). + +Enables highlighting important information using blockquote syntax with five standard alert types: NOTE, TIP, IMPORTANT, WARNING, and CAUTION. + +## Usage + +#### Markdown Syntax + +```markdown +> [!NOTE] +> Useful information + +> [!WARNING] +> Critical information +``` + +#### Standard GFM Types + +```java +Extension extension = AlertsExtension.create(); +Parser parser = Parser.builder().extensions(List.of(extension)).build(); +HtmlRenderer renderer = HtmlRenderer.builder().extensions(List.of(extension)).build(); +``` + +#### Custom Alert Types + +Add custom types beyond the five standard GFM types: + +```java +Extension extension = AlertsExtension.builder() + .addCustomType("INFO", "Information") + .build(); +``` + +Custom types must be UPPERCASE and cannot override standard types. + +#### Styling + +Alerts render as `
` elements with CSS classes: + +```html +
+

Note

+

Content

+
+``` + +Basic CSS example: + +```css +.markdown-alert { + padding: 0.5rem 1rem; + margin-bottom: 1rem; + border-left: 4px solid; +} + +.markdown-alert-note { border-color: #0969da; background-color: #ddf4ff; } +.markdown-alert-tip { border-color: #1a7f37; background-color: #dcffe4; } +.markdown-alert-important { border-color: #8250df; background-color: #f6f0ff; } +.markdown-alert-warning { border-color: #9a6700; background-color: #fff8c5; } +.markdown-alert-caution { border-color: #cf222e; background-color: #ffebe9; } +``` + +Icons can be added using CSS `::before` pseudo-elements with GitHub's [Octicons](https://primer.style/octicons/) (info, light-bulb, report, alert, stop icons). + +## License + +See the main commonmark-java project for license information. diff --git a/commonmark-ext-gfm-alerts/pom.xml b/commonmark-ext-gfm-alerts/pom.xml new file mode 100644 index 00000000..5235af6b --- /dev/null +++ b/commonmark-ext-gfm-alerts/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + org.commonmark + commonmark-parent + 0.27.2-SNAPSHOT + + + commonmark-ext-gfm-alerts + commonmark-java extension for alerts + commonmark-java extension for GFM alerts (admonition blocks) using [!TYPE] syntax (GitHub Flavored Markdown) + + + + org.commonmark + commonmark + + + + org.commonmark + commonmark-test-util + test + + + + diff --git a/commonmark-ext-gfm-alerts/src/main/java/module-info.java b/commonmark-ext-gfm-alerts/src/main/java/module-info.java new file mode 100644 index 00000000..e8b5aecb --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/module-info.java @@ -0,0 +1,5 @@ +module org.commonmark.ext.gfm.alerts { + exports org.commonmark.ext.gfm.alerts; + + requires transitive org.commonmark; +} diff --git a/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/Alert.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/Alert.java new file mode 100644 index 00000000..90af2e22 --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/Alert.java @@ -0,0 +1,27 @@ +package org.commonmark.ext.gfm.alerts; + +import org.commonmark.node.CustomBlock; + +import java.util.Set; + +/** + * Alert block for highlighting important information using {@code [!TYPE]} syntax. + */ +public class Alert extends CustomBlock { + + public static final Set STANDARD_TYPES = Set.of("NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"); + + private String type; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public boolean isStandardType() { + return STANDARD_TYPES.contains(type); + } +} diff --git a/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/AlertsExtension.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/AlertsExtension.java new file mode 100644 index 00000000..96d57b36 --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/AlertsExtension.java @@ -0,0 +1,123 @@ +package org.commonmark.ext.gfm.alerts; + +import org.commonmark.Extension; +import org.commonmark.ext.gfm.alerts.internal.AlertBlockParser; +import org.commonmark.ext.gfm.alerts.internal.AlertHtmlNodeRenderer; +import org.commonmark.ext.gfm.alerts.internal.AlertMarkdownNodeRenderer; +import org.commonmark.ext.gfm.alerts.internal.AlertTextContentNodeRenderer; +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; +import org.commonmark.renderer.text.TextContentRenderer; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Extension for GFM alerts using {@code [!TYPE]} syntax (GitHub Flavored Markdown). + *

+ * Create with {@link #create()} or {@link #builder()} and configure on builders + * ({@link org.commonmark.parser.Parser.Builder#extensions(Iterable)}, + * {@link HtmlRenderer.Builder#extensions(Iterable)}). + * Parsed alerts become {@link Alert} blocks. + */ +public class AlertsExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension, + TextContentRenderer.TextContentRendererExtension, MarkdownRenderer.MarkdownRendererExtension { + + private final Map customTypes; + + private AlertsExtension(Builder builder) { + this.customTypes = new LinkedHashMap<>(builder.customTypes); + } + + public static Extension create() { + return new AlertsExtension(builder()); + } + + public static Builder builder() { + return new Builder(); + } + + public Map getCustomTypes() { + return Collections.unmodifiableMap(customTypes); + } + + @Override + public void extend(Parser.Builder parserBuilder) { + parserBuilder.customBlockParserFactory(new AlertBlockParser.Factory(this)); + } + + @Override + public void extend(HtmlRenderer.Builder rendererBuilder) { + rendererBuilder.nodeRendererFactory(context -> new AlertHtmlNodeRenderer(context, customTypes)); + } + + @Override + public void extend(TextContentRenderer.Builder rendererBuilder) { + rendererBuilder.nodeRendererFactory(AlertTextContentNodeRenderer::new); + } + + @Override + public void extend(MarkdownRenderer.Builder rendererBuilder) { + rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() { + @Override + public NodeRenderer create(MarkdownNodeRendererContext context) { + return new AlertMarkdownNodeRenderer(context); + } + + @Override + public Set getSpecialCharacters() { + return Set.of(); + } + }); + } + + /** + * Builder for configuring the alerts extension. + */ + public static class Builder { + private final Map customTypes = new LinkedHashMap<>(); + + /** + * Adds a custom alert type with a display title. + *

+ * Custom types must: + *

+ * + * @param type the alert type (must be uppercase) + * @param title the display title for this alert type + * @return {@code this} + */ + public Builder addCustomType(String type, String title) { + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("Type must not be null or empty"); + } + if (title == null || title.isEmpty()) { + throw new IllegalArgumentException("Title must not be null or empty"); + } + if (!type.equals(type.toUpperCase())) { + throw new IllegalArgumentException("Type must be uppercase: " + type); + } + if (Alert.STANDARD_TYPES.contains(type)) { + throw new IllegalArgumentException("Cannot override standard GFM type: " + type); + } + customTypes.put(type, title); + return this; + } + + /** + * @return a configured {@link Extension} + */ + public Extension build() { + return new AlertsExtension(this); + } + } +} diff --git a/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertBlockParser.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertBlockParser.java new file mode 100644 index 00000000..30f417ef --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertBlockParser.java @@ -0,0 +1,121 @@ +package org.commonmark.ext.gfm.alerts.internal; + +import org.commonmark.ext.gfm.alerts.Alert; +import org.commonmark.ext.gfm.alerts.AlertsExtension; +import org.commonmark.node.Block; +import org.commonmark.parser.block.*; +import org.commonmark.text.Characters; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class AlertBlockParser extends AbstractBlockParser { + + private static final Pattern ALERT_PATTERN = Pattern.compile("^\\[!([A-Z]+)]$"); + + // Duplicates org.commonmark.internal.util.Parsing.CODE_BLOCK_INDENT which is not accessible + private static final int CODE_BLOCK_INDENT = 4; + + private final Alert block = new Alert(); + + public AlertBlockParser(String type) { + if (type == null) { + throw new IllegalArgumentException("Alert type must not be null"); + } + block.setType(type); + } + + @Override + public boolean isContainer() { + return true; + } + + @Override + public boolean canContain(Block block) { + return true; + } + + @Override + public Block getBlock() { + return block; + } + + @Override + public BlockContinue tryContinue(ParserState state) { + int nextNonSpace = state.getNextNonSpaceIndex(); + if (isMarker(state, nextNonSpace)) { + int newColumn = state.getColumn() + state.getIndent() + 1; + // optional following space or tab + if (Characters.isSpaceOrTab(state.getLine().getContent(), nextNonSpace + 1)) { + newColumn++; + } + return BlockContinue.atColumn(newColumn); + } else { + return BlockContinue.none(); + } + } + + /** + * Checks if the character at the given index is a blockquote marker ('>'). + * + * @param state the parser state + * @param index the index to check + * @return true if the character is '>' and indentation is less than CODE_BLOCK_INDENT + */ + private static boolean isMarker(ParserState state, int index) { + CharSequence line = state.getLine().getContent(); + return state.getIndent() < CODE_BLOCK_INDENT && index < line.length() && line.charAt(index) == '>'; + } + + /** + * Factory for creating alert block parsers. + */ + public static class Factory extends AbstractBlockParserFactory { + private final Set allowedTypes; + + public Factory(AlertsExtension extension) { + // Combine standard GFM types with custom types + this.allowedTypes = new HashSet<>(Alert.STANDARD_TYPES); + this.allowedTypes.addAll(extension.getCustomTypes().keySet()); + } + + @Override + public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { + int nextNonSpace = state.getNextNonSpaceIndex(); + if (!isMarker(state, nextNonSpace)) { + return BlockStart.none(); + } + + // Check if this is an alert by looking for [!TYPE] pattern + CharSequence line = state.getLine().getContent(); + int contentStart = nextNonSpace + 1; + // optional following space or tab + if (Characters.isSpaceOrTab(line, contentStart)) { + contentStart++; + } + + // Look for [!TYPE] pattern after trimming whitespace + if (contentStart < line.length()) { + String content = line.subSequence(contentStart, line.length()).toString().trim(); + Matcher matcher = ALERT_PATTERN.matcher(content); + if (matcher.matches()) { + String type = matcher.group(1); + + // Check if this type is allowed (standard or custom) + if (allowedTypes.contains(type)) { + // Skip the entire first line (the marker line) by advancing to the end. + // This is different from regular blockquotes which calculate column position, + // because we want to consume the [!TYPE] marker completely and not render it. + int newColumn = line.length(); + + return BlockStart.of(new AlertBlockParser(type)).atColumn(newColumn); + } + } + } + + return BlockStart.none(); + } + } +} diff --git a/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertHtmlNodeRenderer.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertHtmlNodeRenderer.java new file mode 100644 index 00000000..f965d179 --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertHtmlNodeRenderer.java @@ -0,0 +1,98 @@ +package org.commonmark.ext.gfm.alerts.internal; + +import org.commonmark.ext.gfm.alerts.Alert; +import org.commonmark.node.Node; +import org.commonmark.renderer.html.HtmlNodeRendererContext; +import org.commonmark.renderer.html.HtmlWriter; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class AlertHtmlNodeRenderer extends AlertNodeRenderer { + + private final HtmlWriter htmlWriter; + private final HtmlNodeRendererContext context; + private final Map customTypeTitles; + + public AlertHtmlNodeRenderer(HtmlNodeRendererContext context, Map customTypeTitles) { + this.htmlWriter = context.getWriter(); + this.context = context; + this.customTypeTitles = customTypeTitles; + } + + @Override + protected void renderAlert(Alert alert) { + String type = alert.getType(); + String cssClass = type.toLowerCase(); + + htmlWriter.line(); + Map attributes = new LinkedHashMap<>(); + attributes.put("class", "markdown-alert markdown-alert-" + cssClass); + attributes.put("data-alert-type", cssClass); + + htmlWriter.tag("div", context.extendAttributes(alert, "div", attributes)); + htmlWriter.line(); + + // Render alert title + htmlWriter.tag("p", Map.of("class", "markdown-alert-title")); + htmlWriter.text(getAlertTitle(type)); + htmlWriter.tag("/p"); + htmlWriter.line(); + + // Render children (the alert content) + renderChildren(alert); + + htmlWriter.tag("/div"); + htmlWriter.line(); + } + + /** + * Gets the display title for an alert type. + *

+ * For standard GFM types, uses the predefined titles. + * For custom types, uses the configured title or capitalizes the type name. + *

+ * + * @param type the alert type + * @return the display title (e.g., "Note", "Important", or custom title) + */ + private String getAlertTitle(String type) { + // Check if it's a custom type + if (customTypeTitles.containsKey(type)) { + return customTypeTitles.get(type); + } + + // Standard GFM types + switch (type) { + case "NOTE": + return "Note"; + case "TIP": + return "Tip"; + case "IMPORTANT": + return "Important"; + case "WARNING": + return "Warning"; + case "CAUTION": + return "Caution"; + default: + // Fallback: capitalize the type + return capitalize(type); + } + } + + private String capitalize(String s) { + if (s == null || s.isEmpty()) { + return s; + } + return s.substring(0, 1).toUpperCase() + s.substring(1).toLowerCase(); + } + + private void renderChildren(Node parent) { + Node node = parent.getFirstChild(); + while (node != null) { + Node next = node.getNext(); + context.render(node); + node = next; + } + } +} diff --git a/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertMarkdownNodeRenderer.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertMarkdownNodeRenderer.java new file mode 100644 index 00000000..ff71e79e --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertMarkdownNodeRenderer.java @@ -0,0 +1,38 @@ +package org.commonmark.ext.gfm.alerts.internal; + +import org.commonmark.ext.gfm.alerts.Alert; +import org.commonmark.node.Node; +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownWriter; + +public class AlertMarkdownNodeRenderer extends AlertNodeRenderer { + + private final MarkdownWriter writer; + private final MarkdownNodeRendererContext context; + + public AlertMarkdownNodeRenderer(MarkdownNodeRendererContext context) { + this.writer = context.getWriter(); + this.context = context; + } + + @Override + protected void renderAlert(Alert alert) { + writer.line(); + writer.raw("> [!" + alert.getType() + "]"); + writer.line(); + + // Render children - they will be indented by the blockquote + renderChildren(alert); + + writer.line(); + } + + private void renderChildren(Node parent) { + Node node = parent.getFirstChild(); + while (node != null) { + Node next = node.getNext(); + context.render(node); + node = next; + } + } +} diff --git a/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertNodeRenderer.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertNodeRenderer.java new file mode 100644 index 00000000..dcb1a25e --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertNodeRenderer.java @@ -0,0 +1,22 @@ +package org.commonmark.ext.gfm.alerts.internal; + +import org.commonmark.ext.gfm.alerts.Alert; +import org.commonmark.renderer.NodeRenderer; + +import java.util.Set; + +public abstract class AlertNodeRenderer implements NodeRenderer { + + @Override + public Set> getNodeTypes() { + return Set.of(Alert.class); + } + + @Override + public void render(org.commonmark.node.Node node) { + Alert alert = (Alert) node; + renderAlert(alert); + } + + protected abstract void renderAlert(Alert alert); +} diff --git a/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertTextContentNodeRenderer.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertTextContentNodeRenderer.java new file mode 100644 index 00000000..9c6c66c9 --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertTextContentNodeRenderer.java @@ -0,0 +1,28 @@ +package org.commonmark.ext.gfm.alerts.internal; + +import org.commonmark.ext.gfm.alerts.Alert; +import org.commonmark.node.Node; +import org.commonmark.renderer.text.TextContentNodeRendererContext; + +public class AlertTextContentNodeRenderer extends AlertNodeRenderer { + + private final TextContentNodeRendererContext context; + + public AlertTextContentNodeRenderer(TextContentNodeRendererContext context) { + this.context = context; + } + + @Override + protected void renderAlert(Alert alert) { + renderChildren(alert); + } + + private void renderChildren(Node parent) { + Node node = parent.getFirstChild(); + while (node != null) { + Node next = node.getNext(); + context.render(node); + node = next; + } + } +} diff --git a/commonmark-ext-gfm-alerts/src/main/javadoc/overview.html b/commonmark-ext-gfm-alerts/src/main/javadoc/overview.html new file mode 100644 index 00000000..145232a8 --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/main/javadoc/overview.html @@ -0,0 +1,6 @@ + + +Extension for GitHub Flavored Markdown (GFM) alerts using blockquote syntax +

See {@link org.commonmark.ext.gfm.alerts.AlertsExtension}

+ + diff --git a/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsComplexContentTest.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsComplexContentTest.java new file mode 100644 index 00000000..d9293ccb --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsComplexContentTest.java @@ -0,0 +1,76 @@ +package org.commonmark.ext.gfm.alerts; + +import org.commonmark.Extension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.testutil.RenderingTestCase; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +/** + * Tests for alerts containing complex content like code blocks, lists, etc. + */ +public class AlertsComplexContentTest extends RenderingTestCase { + + private static final Set EXTENSIONS = Set.of(AlertsExtension.create()); + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).build(); + + @Test + public void alertWithCodeBlock() { + assertRendering("> [!TIP]\n> Here's some code:\n>\n> function() { }\n>\n> End of tip", + "
\n" + + "

Tip

\n" + + "

Here's some code:

\n" + + "
function() { }\n
\n" + + "

End of tip

\n" + + "
\n"); + } + + @Test + public void alertWithList() { + assertRendering("> [!IMPORTANT]\n> Items to remember:\n> - First item\n> - Second item", + "
\n" + + "

Important

\n" + + "

Items to remember:

\n" + + "
    \n" + + "
  • First item
  • \n" + + "
  • Second item
  • \n" + + "
\n" + + "
\n"); + } + + @Test + public void alertWithLinks() { + assertRendering("> [!NOTE]\n> Check out [this link](https://example.com) for more info", + "
\n" + + "

Note

\n" + + "

Check out this link for more info

\n" + + "
\n"); + } + + @Test + public void alertWithSpecialCharacters() { + assertRendering("> [!WARNING]\n> Special chars: & < > \" ' / \\ ` * _", + "
\n" + + "

Warning

\n" + + "

Special chars: & < > " ' / \\ ` * _

\n" + + "
\n"); + } + + @Test + public void alertWithHeading() { + assertRendering("> [!IMPORTANT]\n> ## Heading\n> Content below heading", + "
\n" + + "

Important

\n" + + "

Heading

\n" + + "

Content below heading

\n" + + "
\n"); + } + + @Override + protected String render(String source) { + return HTML_RENDERER.render(PARSER.parse(source)); + } +} diff --git a/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsCustomTypesTest.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsCustomTypesTest.java new file mode 100644 index 00000000..3bdc7e31 --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsCustomTypesTest.java @@ -0,0 +1,143 @@ +package org.commonmark.ext.gfm.alerts; + +import org.commonmark.Extension; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.testutil.RenderingTestCase; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for custom alert types functionality. + */ +public class AlertsCustomTypesTest extends RenderingTestCase { + + @Test + public void customTypeWithConfiguration() { + Extension extension = AlertsExtension.builder() + .addCustomType("INFO", "Information") + .build(); + + Parser parser = Parser.builder().extensions(Set.of(extension)).build(); + HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build(); + + String html = renderer.render(parser.parse("> [!INFO]\n> Custom alert")); + + assertThat(html).contains("markdown-alert-info"); + assertThat(html).contains("Information"); + } + + @Test + public void multipleCustomTypes() { + Extension extension = AlertsExtension.builder() + .addCustomType("INFO", "Information") + .addCustomType("SUCCESS", "Success") + .addCustomType("DANGER", "Danger") + .build(); + + Parser parser = Parser.builder().extensions(Set.of(extension)).build(); + HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build(); + + String markdown = "> [!INFO]\n> Info\n\n> [!SUCCESS]\n> Success\n\n> [!DANGER]\n> Danger"; + String html = renderer.render(parser.parse(markdown)); + + assertThat(html).contains("markdown-alert-info"); + assertThat(html).contains("Information"); + assertThat(html).contains("markdown-alert-success"); + assertThat(html).contains("Success"); + assertThat(html).contains("markdown-alert-danger"); + assertThat(html).contains("Danger"); + } + + @Test + public void customTypeWithoutConfigurationIsBlockquote() { + Extension extension = AlertsExtension.create(); // No custom types + + Parser parser = Parser.builder().extensions(Set.of(extension)).build(); + HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build(); + + String html = renderer.render(parser.parse("> [!INFO]\n> Should be blockquote")); + + assertThat(html).contains("
"); + assertThat(html).doesNotContain("markdown-alert"); + } + + @Test + public void standardTypesStillWork() { + Extension extension = AlertsExtension.builder() + .addCustomType("INFO", "Information") + .build(); + + Parser parser = Parser.builder().extensions(Set.of(extension)).build(); + HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build(); + + String html = renderer.render(parser.parse("> [!NOTE]\n> Standard type")); + + assertThat(html).contains("markdown-alert-note"); + assertThat(html).contains("Note"); + } + + @Test + public void customTypeMustBeUppercase() { + assertThrows(IllegalArgumentException.class, () -> { + AlertsExtension.builder() + .addCustomType("info", "Information") + .build(); + }); + } + + @Test + public void customTypeCannotOverrideStandardType() { + assertThrows(IllegalArgumentException.class, () -> { + AlertsExtension.builder() + .addCustomType("NOTE", "Custom Note") + .build(); + }); + } + + @Test + public void customTypeMustNotBeEmpty() { + assertThrows(IllegalArgumentException.class, () -> { + AlertsExtension.builder() + .addCustomType("", "Title") + .build(); + }); + } + + @Test + public void customTypeTitleMustNotBeEmpty() { + assertThrows(IllegalArgumentException.class, () -> { + AlertsExtension.builder() + .addCustomType("INFO", "") + .build(); + }); + } + + @Test + public void customTypeIsNotStandardType() { + Extension extension = AlertsExtension.builder() + .addCustomType("INFO", "Information") + .build(); + + Parser parser = Parser.builder().extensions(Set.of(extension)).build(); + + Node document = parser.parse("> [!INFO]\n> Custom alert"); + Alert alert = (Alert) document.getFirstChild(); + + assertThat(alert.isStandardType()).isFalse(); + assertThat(alert.getType()).isEqualTo("INFO"); + } + + @Override + protected String render(String source) { + Extension extension = AlertsExtension.create(); + Parser parser = Parser.builder().extensions(Set.of(extension)).build(); + HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build(); + return renderer.render(parser.parse(source)); + } +} diff --git a/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsEdgeCasesTest.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsEdgeCasesTest.java new file mode 100644 index 00000000..8fc9d03d --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsEdgeCasesTest.java @@ -0,0 +1,228 @@ +package org.commonmark.ext.gfm.alerts; + +import org.commonmark.Extension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.testutil.RenderingTestCase; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for edge cases and invalid inputs. + */ +public class AlertsEdgeCasesTest extends RenderingTestCase { + + private static final Set EXTENSIONS = Set.of(AlertsExtension.create()); + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).build(); + + @Test + public void notAnAlertWithTextAfterMarker() { + assertRendering("> [!NOTE] Some text", + "
\n" + + "

[!NOTE] Some text

\n" + + "
\n"); + } + + @Test + public void notAnAlertWithInvalidType() { + assertRendering("> [!INVALID]\n> Some text", + "
\n" + + "

[!INVALID]\nSome text

\n" + + "
\n"); + } + + @Test + public void emptyAlertWithNoContent() { + assertRendering("> [!NOTE]", + "
\n" + + "

Note

\n" + + "
\n"); + } + + @Test + public void alertWithOnlyWhitespaceContent() { + assertRendering("> [!TIP]\n> \n> ", + "
\n" + + "

Tip

\n" + + "
\n"); + } + + @Test + public void lowercaseAlertTypeShouldNotMatch() { + assertRendering("> [!note]\n> This should be a blockquote", + "
\n" + + "

[!note]\nThis should be a blockquote

\n" + + "
\n"); + } + + @Test + public void mixedCaseAlertTypeShouldNotMatch() { + assertRendering("> [!Note]\n> This should be a blockquote", + "
\n" + + "

[!Note]\nThis should be a blockquote

\n" + + "
\n"); + } + + @Test + public void nestedAlertMarkersAreTreatedAsText() { + assertRendering("> [!NOTE]\n> This is a note\n> [!WARNING]\n> This is still part of the note", + "
\n" + + "

Note

\n" + + "

This is a note\n[!WARNING]\nThis is still part of the note

\n" + + "
\n"); + } + + @Test + public void emptyLineImmediatelyAfterMarkerEndsAlert() { + assertRendering("> [!NOTE]\n\nSome text", + "
\n" + + "

Note

\n" + + "
\n" + + "

Some text

\n"); + } + + @Test + public void alertWithLeadingSpacesBeforeMarker() { + assertRendering(" > [!IMPORTANT]\n > Content", + "
\n" + + "

Important

\n" + + "

Content

\n" + + "
\n"); + } + + @Test + public void alertWithTrailingTabsAfterMarker() { + assertRendering("> [!WARNING]\t\t\n> Be careful", + "
\n" + + "

Warning

\n" + + "

Be careful

\n" + + "
\n"); + } + + @Test + public void adjacentAlerts() { + assertRendering("> [!NOTE]\n> First alert\n\n> [!WARNING]\n> Second alert", + "
\n" + + "

Note

\n" + + "

First alert

\n" + + "
\n" + + "
\n" + + "

Warning

\n" + + "

Second alert

\n" + + "
\n"); + } + + @Test + public void alertInterruptedByNonBlockquoteLine() { + assertRendering("> [!CAUTION]\n> First line\nLazy continuation\n> Continues alert", + "
\n" + + "

Caution

\n" + + "

First line\nLazy continuation\nContinues alert

\n" + + "
\n"); + } + + @Test + public void alertWithLongContent() { + String longContent = "> [!CAUTION]\n> " + "This is a very long line of text. ".repeat(10).trim(); + String result = render(longContent); + assertThat(result).startsWith("
[!TIP]\n> Last alert", + "
\n" + + "

Tip

\n" + + "

Last alert

\n" + + "
\n"); + } + + @Test + public void alertWithEmptyLinesInMiddle() { + assertRendering("> [!NOTE]\n> First\n>\n>\n> After empty lines", + "
\n" + + "

Note

\n" + + "

First

\n" + + "

After empty lines

\n" + + "
\n"); + } + + @Test + public void alertWithOnlyMarkerAndWhitespaceLine() { + assertRendering("> [!IMPORTANT]\n> ", + "
\n" + + "

Important

\n" + + "
\n"); + } + + @Test + public void markerWithExtraSpacesInside() { + assertRendering("> [! NOTE]\n> Should be blockquote", + "
\n" + + "

[! NOTE]\nShould be blockquote

\n" + + "
\n"); + } + + @Test + public void markerWithMissingBrackets() { + assertRendering("> !NOTE\n> Should be blockquote", + "
\n" + + "

!NOTE\nShould be blockquote

\n" + + "
\n"); + } + + @Test + public void markerWithMissingExclamation() { + assertRendering("> [NOTE]\n> Should be blockquote", + "
\n" + + "

[NOTE]\nShould be blockquote

\n" + + "
\n"); + } + + @Test + public void alertTypeAfterRegularBlockquote() { + assertRendering("> Regular blockquote\n> [!NOTE]\n> More text", + "
\n" + + "

Regular blockquote\n[!NOTE]\nMore text

\n" + + "
\n"); + } + + @Test + public void alertWithLazyContinuation() { + assertRendering("> [!NOTE]\n> First line\nLazy continuation", + "
\n" + + "

Note

\n" + + "

First line\nLazy continuation

\n" + + "
\n"); + } + + @Test + public void alertEndedByBlankLineThenContent() { + assertRendering("> [!WARNING]\n> Content\n\nNot part of alert", + "
\n" + + "

Warning

\n" + + "

Content

\n" + + "
\n" + + "

Not part of alert

\n"); + } + + @Test + public void alertEndedByIndentedCode() { + assertRendering("> [!TIP]\n> Content\n\n code", + "
\n" + + "

Tip

\n" + + "

Content

\n" + + "
\n" + + "
code\n
\n"); + } + + @Override + protected String render(String source) { + return HTML_RENDERER.render(PARSER.parse(source)); + } +} diff --git a/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsMarkdownRendererTest.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsMarkdownRendererTest.java new file mode 100644 index 00000000..db13541f --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsMarkdownRendererTest.java @@ -0,0 +1,54 @@ +package org.commonmark.ext.gfm.alerts; + +import org.commonmark.Extension; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.markdown.MarkdownRenderer; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for rendering alerts back to Markdown. + */ +public class AlertsMarkdownRendererTest { + + private static final Set EXTENSIONS = Set.of(AlertsExtension.create()); + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + private static final MarkdownRenderer MARKDOWN_RENDERER = MarkdownRenderer.builder().extensions(EXTENSIONS).build(); + + @Test + public void markdownRendererPreservesAlert() { + String markdown = "> [!WARNING]\n> Be careful"; + Node document = PARSER.parse(markdown); + String rendered = MARKDOWN_RENDERER.render(document); + assertThat(rendered).contains("[!WARNING]"); + } + + @Test + public void markdownRendererPreservesAllTypes() { + String markdown = "> [!NOTE]\n> Note\n\n> [!TIP]\n> Tip\n\n> [!IMPORTANT]\n> Important"; + Node document = PARSER.parse(markdown); + String rendered = MARKDOWN_RENDERER.render(document); + assertThat(rendered).contains("[!NOTE]"); + assertThat(rendered).contains("[!TIP]"); + assertThat(rendered).contains("[!IMPORTANT]"); + } + + @Test + public void markdownRendererPreservesCustomTypes() { + Extension extension = AlertsExtension.builder() + .addCustomType("INFO", "Information") + .build(); + + Parser parser = Parser.builder().extensions(Set.of(extension)).build(); + MarkdownRenderer renderer = MarkdownRenderer.builder().extensions(Set.of(extension)).build(); + + String markdown = "> [!INFO]\n> Custom type"; + Node document = parser.parse(markdown); + String rendered = renderer.render(document); + assertThat(rendered).contains("[!INFO]"); + } +} diff --git a/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsTest.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsTest.java new file mode 100644 index 00000000..8c3849f5 --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsTest.java @@ -0,0 +1,130 @@ +package org.commonmark.ext.gfm.alerts; + +import org.commonmark.Extension; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.testutil.RenderingTestCase; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for basic alert functionality with standard GFM types. + */ +public class AlertsTest extends RenderingTestCase { + + private static final Set EXTENSIONS = Set.of(AlertsExtension.create()); + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).build(); + + @Test + public void noteAlert() { + assertRendering("> [!NOTE]\n> This is a note", + "
\n" + + "

Note

\n" + + "

This is a note

\n" + + "
\n"); + } + + @Test + public void tipAlert() { + assertRendering("> [!TIP]\n> This is a tip", + "
\n" + + "

Tip

\n" + + "

This is a tip

\n" + + "
\n"); + } + + @Test + public void importantAlert() { + assertRendering("> [!IMPORTANT]\n> This is important", + "
\n" + + "

Important

\n" + + "

This is important

\n" + + "
\n"); + } + + @Test + public void warningAlert() { + assertRendering("> [!WARNING]\n> This is a warning", + "
\n" + + "

Warning

\n" + + "

This is a warning

\n" + + "
\n"); + } + + @Test + public void cautionAlert() { + assertRendering("> [!CAUTION]\n> This is a caution", + "
\n" + + "

Caution

\n" + + "

This is a caution

\n" + + "
\n"); + } + + @Test + public void alertWithMultipleParagraphs() { + assertRendering("> [!NOTE]\n> First paragraph\n>\n> Second paragraph", + "
\n" + + "

Note

\n" + + "

First paragraph

\n" + + "

Second paragraph

\n" + + "
\n"); + } + + @Test + public void alertWithInlineFormatting() { + assertRendering("> [!TIP]\n> This is **bold** and *italic*", + "
\n" + + "

Tip

\n" + + "

This is bold and italic

\n" + + "
\n"); + } + + @Test + public void regularBlockquoteStillWorks() { + assertRendering("> This is a regular blockquote", + "
\n" + + "

This is a regular blockquote

\n" + + "
\n"); + } + + @Test + public void alertFollowedByBlockquote() { + assertRendering("> [!NOTE]\n> This is an alert\n\n> This is a blockquote", + "
\n" + + "

Note

\n" + + "

This is an alert

\n" + + "
\n" + + "
\n" + + "

This is a blockquote

\n" + + "
\n"); + } + + @Test + public void alertParsedAsAlertNode() { + Node document = PARSER.parse("> [!NOTE]\n> This is a note"); + Node firstChild = document.getFirstChild(); + assertThat(firstChild).isInstanceOf(Alert.class); + Alert alert = (Alert) firstChild; + assertThat(alert.getType()).isEqualTo("NOTE"); + assertThat(alert.isStandardType()).isTrue(); + } + + @Test + public void alertWithTrailingSpacesAfterMarker() { + assertRendering("> [!NOTE] \n> This is a note", + "
\n" + + "

Note

\n" + + "

This is a note

\n" + + "
\n"); + } + + @Override + protected String render(String source) { + return HTML_RENDERER.render(PARSER.parse(source)); + } +} diff --git a/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/examples/AlertsExample.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/examples/AlertsExample.java new file mode 100644 index 00000000..dba1628c --- /dev/null +++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/examples/AlertsExample.java @@ -0,0 +1,94 @@ +package org.commonmark.ext.gfm.alerts.examples; + +import org.commonmark.Extension; +import org.commonmark.ext.gfm.alerts.AlertsExtension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + +import java.util.List; + +/** + * Example demonstrating the use of the GFM Alerts extension. + */ +public class AlertsExample { + + public static void main(String[] args) { + standardTypesExample(); + System.out.println("\n" + "=".repeat(60) + "\n"); + customTypesExample(); + } + + private static void standardTypesExample() { + System.out.println("STANDARD GFM ALERT TYPES"); + System.out.println("=".repeat(60)); + + // Create the alerts extension with default settings + Extension extension = AlertsExtension.create(); + + Parser parser = Parser.builder() + .extensions(List.of(extension)) + .build(); + + HtmlRenderer renderer = HtmlRenderer.builder() + .extensions(List.of(extension)) + .build(); + + // Example markdown with all standard alert types + String markdown = "# GFM Alerts Demo\n\n" + + "> [!NOTE]\n" + + "> Highlights information that users should take into account.\n\n" + + "> [!TIP]\n" + + "> Helpful advice for doing things better.\n\n" + + "> [!IMPORTANT]\n" + + "> Key information users need to know.\n\n" + + "> [!WARNING]\n" + + "> Urgent info that needs immediate attention.\n\n" + + "> [!CAUTION]\n" + + "> Advises about risks or negative outcomes.\n"; + + String html = renderer.render(parser.parse(markdown)); + + System.out.println("Markdown Input:"); + System.out.println(markdown); + System.out.println("\nHTML Output:"); + System.out.println(html); + } + + private static void customTypesExample() { + System.out.println("CUSTOM ALERT TYPES"); + System.out.println("=".repeat(60)); + + // Create extension with custom types + Extension extension = AlertsExtension.builder() + .addCustomType("INFO", "Information") + .addCustomType("SUCCESS", "Success") + .addCustomType("DANGER", "Danger") + .build(); + + Parser parser = Parser.builder() + .extensions(List.of(extension)) + .build(); + + HtmlRenderer renderer = HtmlRenderer.builder() + .extensions(List.of(extension)) + .build(); + + // Example markdown with custom alert types + String markdown = "# Custom Alert Types\n\n" + + "> [!INFO]\n" + + "> This is a custom information alert.\n\n" + + "> [!SUCCESS]\n" + + "> Operation completed successfully!\n\n" + + "> [!DANGER]\n" + + "> This action is dangerous and irreversible.\n\n" + + "> [!NOTE]\n" + + "> Standard types still work alongside custom types.\n"; + + String html = renderer.render(parser.parse(markdown)); + + System.out.println("Markdown Input:"); + System.out.println(markdown); + System.out.println("\nHTML Output:"); + System.out.println(html); + } +} diff --git a/pom.xml b/pom.xml index d55d1fd8..58c3ead2 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ commonmark commonmark-ext-autolink commonmark-ext-footnotes + commonmark-ext-gfm-alerts commonmark-ext-gfm-strikethrough commonmark-ext-gfm-tables commonmark-ext-heading-anchor