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
+
+```
+
+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..bb28e734
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/Alert.java
@@ -0,0 +1,19 @@
+package org.commonmark.ext.gfm.alerts;
+
+import org.commonmark.node.CustomBlock;
+
+/**
+ * Alert block for highlighting important information using {@code [!TYPE]} syntax.
+ */
+public class Alert extends CustomBlock {
+
+ private final String type;
+
+ public Alert(String type) {
+ this.type = type;
+ }
+
+ public String getType() {
+ return 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..63a69f74
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/AlertsExtension.java
@@ -0,0 +1,117 @@
+package org.commonmark.ext.gfm.alerts;
+
+import org.commonmark.Extension;
+import org.commonmark.ext.gfm.alerts.internal.AlertPostProcessor;
+import org.commonmark.ext.gfm.alerts.internal.AlertHtmlNodeRenderer;
+import org.commonmark.ext.gfm.alerts.internal.AlertMarkdownNodeRenderer;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.NodeRenderer;
+import org.commonmark.renderer.html.HtmlNodeRendererContext;
+import org.commonmark.renderer.html.HtmlNodeRendererFactory;
+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 java.util.HashSet;
+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,
+ MarkdownRenderer.MarkdownRendererExtension {
+
+ static final Set STANDARD_TYPES = Set.of("NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION");
+
+ private final Map customTypes;
+
+ private AlertsExtension(Builder builder) {
+ this.customTypes = new LinkedHashMap<>(builder.customTypes);
+ }
+
+ public static Extension create() {
+ return builder().build();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ public void extend(Parser.Builder parserBuilder) {
+ Set allowedTypes = new HashSet<>(STANDARD_TYPES);
+ allowedTypes.addAll(customTypes.keySet());
+ parserBuilder.postProcessor(new AlertPostProcessor(allowedTypes));
+ }
+
+ @Override
+ public void extend(HtmlRenderer.Builder rendererBuilder) {
+ rendererBuilder.nodeRendererFactory(new HtmlNodeRendererFactory() {
+ @Override
+ public NodeRenderer create(HtmlNodeRendererContext context) {
+ return new AlertHtmlNodeRenderer(context, customTypes);
+ }
+ });
+ }
+
+ @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.
+ *
+ * This can also be used to override the display title of standard GFM types
+ * (e.g., for localization).
+ *
+ * @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);
+ }
+ 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/AlertHtmlNodeRenderer.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertHtmlNodeRenderer.java
new file mode 100644
index 00000000..202c2029
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertHtmlNodeRenderer.java
@@ -0,0 +1,77 @@
+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();
+ }
+
+ private String getAlertTitle(String type) {
+ if (customTypeTitles.containsKey(type)) {
+ return customTypeTitles.get(type);
+ }
+ switch (type) {
+ case "NOTE":
+ return "Note";
+ case "TIP":
+ return "Tip";
+ case "IMPORTANT":
+ return "Important";
+ case "WARNING":
+ return "Warning";
+ case "CAUTION":
+ return "Caution";
+ default:
+ throw new IllegalStateException("Unknown alert type: " + type);
+ }
+ }
+
+ 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..edc988c9
--- /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) {
+ // First line: > [!TYPE]
+ writer.writePrefix("> ");
+ writer.pushPrefix("> ");
+ writer.raw("[!" + alert.getType() + "]");
+ writer.line();
+ renderChildren(alert);
+ writer.popPrefix();
+ writer.block();
+ }
+
+ 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/AlertPostProcessor.java b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertPostProcessor.java
new file mode 100644
index 00000000..0d02c352
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertPostProcessor.java
@@ -0,0 +1,170 @@
+package org.commonmark.ext.gfm.alerts.internal;
+
+import org.commonmark.ext.gfm.alerts.Alert;
+import org.commonmark.node.*;
+import org.commonmark.parser.PostProcessor;
+
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class AlertPostProcessor implements PostProcessor {
+
+ // Case-insensitive matching for alert type (GitHub supports any case)
+ private static final Pattern ALERT_PATTERN = Pattern.compile("^\\[!([a-zA-Z]+)]\\s*$");
+
+ private final Set allowedTypes;
+
+ public AlertPostProcessor(Set allowedTypes) {
+ this.allowedTypes = allowedTypes;
+ }
+
+ @Override
+ public Node process(Node document) {
+ AlertVisitor visitor = new AlertVisitor(allowedTypes);
+ document.accept(visitor);
+ return document;
+ }
+
+ private static class AlertVisitor extends AbstractVisitor {
+
+ private final Set allowedTypes;
+
+ AlertVisitor(Set allowedTypes) {
+ this.allowedTypes = allowedTypes;
+ }
+
+ @Override
+ public void visit(BlockQuote blockQuote) {
+ // Only convert top-level block quotes (direct children of Document).
+ // This matches GitHub's behavior where alerts are only detected at the document level.
+ if (blockQuote.getParent() instanceof Document) {
+ if (tryConvertToAlert(blockQuote)) {
+ return;
+ }
+ }
+ visitChildren(blockQuote);
+ }
+
+ private boolean tryConvertToAlert(BlockQuote blockQuote) {
+ Node firstChild = blockQuote.getFirstChild();
+ if (!(firstChild instanceof Paragraph)) {
+ return false;
+ }
+
+ Paragraph paragraph = (Paragraph) firstChild;
+ Node firstInline = paragraph.getFirstChild();
+ if (!(firstInline instanceof Text)) {
+ return false;
+ }
+
+ Text textNode = (Text) firstInline;
+ String literal = textNode.getLiteral();
+
+ // The alert marker can be the entire text node content, or just the first line
+ // before a line break. We need to check both cases.
+ // Trailing spaces on the marker line create a HardLineBreak instead of SoftLineBreak.
+ String markerText;
+ Node afterMarker = firstInline.getNext();
+ if (afterMarker instanceof SoftLineBreak || afterMarker instanceof HardLineBreak || afterMarker == null) {
+ markerText = literal;
+ } else {
+ // Text followed by something other than a line break on same line - not an alert
+ return false;
+ }
+
+ Matcher matcher = ALERT_PATTERN.matcher(markerText);
+ if (!matcher.matches()) {
+ return false;
+ }
+
+ String type = matcher.group(1).toUpperCase();
+ if (!allowedTypes.contains(type)) {
+ return false;
+ }
+
+ // Must have content after the marker line. An alert with ONLY the marker
+ // and no content is a normal blockquote on GitHub.
+ boolean hasContentAfterMarker;
+ if (afterMarker instanceof SoftLineBreak || afterMarker instanceof HardLineBreak) {
+ // There's a line break after marker - check if there's content after it
+ hasContentAfterMarker = afterMarker.getNext() != null || paragraph.getNext() != null;
+ } else {
+ // Marker is the only thing in this text node
+ hasContentAfterMarker = paragraph.getNext() != null;
+ }
+
+ if (!hasContentAfterMarker) {
+ return false;
+ }
+
+ // Check if the content after the marker is only whitespace
+ if (isContentWhitespaceOnly(paragraph, firstInline)) {
+ return false;
+ }
+
+ // Valid alert. Create Alert node and transfer children.
+ Alert alert = new Alert(type);
+
+ blockQuote.insertAfter(alert);
+
+ // Remove the marker text and line break from the first paragraph
+ if (afterMarker instanceof SoftLineBreak || afterMarker instanceof HardLineBreak) {
+ afterMarker.unlink();
+ }
+ firstInline.unlink();
+
+ // If paragraph is now empty, remove it
+ if (paragraph.getFirstChild() == null) {
+ paragraph.unlink();
+ }
+
+ // Move remaining children from blockquote to alert
+ Node child = blockQuote.getFirstChild();
+ while (child != null) {
+ Node next = child.getNext();
+ alert.appendChild(child);
+ child = next;
+ }
+
+ blockQuote.unlink();
+ return true;
+ }
+
+ private boolean isContentWhitespaceOnly(Paragraph firstParagraph, Node markerNode) {
+ // Check inline nodes after the marker in the first paragraph
+ Node next = markerNode.getNext();
+ while (next != null) {
+ if (next instanceof Text) {
+ if (!((Text) next).getLiteral().trim().isEmpty()) {
+ return false;
+ }
+ } else if (!(next instanceof SoftLineBreak) && !(next instanceof HardLineBreak)) {
+ return false;
+ }
+ next = next.getNext();
+ }
+
+ // Check block-level siblings after the first paragraph
+ Node block = firstParagraph.getNext();
+ while (block != null) {
+ if (block instanceof Paragraph) {
+ Node child = block.getFirstChild();
+ while (child != null) {
+ if (child instanceof Text && !((Text) child).getLiteral().trim().isEmpty()) {
+ return false;
+ } else if (!(child instanceof Text) && !(child instanceof SoftLineBreak) && !(child instanceof HardLineBreak)) {
+ return false;
+ }
+ child = child.getNext();
+ }
+ } else {
+ return false;
+ }
+ block = block.getNext();
+ }
+
+ return true;
+ }
+ }
+}
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/AlertsMarkdownRendererTest.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsMarkdownRendererTest.java
new file mode 100644
index 00000000..aca90e2d
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsMarkdownRendererTest.java
@@ -0,0 +1,73 @@
+package org.commonmark.ext.gfm.alerts;
+
+import org.commonmark.Extension;
+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;
+
+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 RENDERER = MarkdownRenderer.builder().extensions(EXTENSIONS).build();
+
+ @Test
+ public void alertRoundTrip() {
+ assertRoundTrip("> [!WARNING]\n> Be careful\n");
+ }
+
+ @Test
+ public void allStandardTypesRoundTrip() {
+ assertRoundTrip("> [!NOTE]\n> Note\n");
+ assertRoundTrip("> [!TIP]\n> Tip\n");
+ assertRoundTrip("> [!IMPORTANT]\n> Important\n");
+ assertRoundTrip("> [!WARNING]\n> Warning\n");
+ assertRoundTrip("> [!CAUTION]\n> Caution\n");
+ }
+
+ @Test
+ public void lowercaseTypeRendersAsUppercase() {
+ // Lowercase input gets normalized to uppercase type
+ String rendered = RENDERER.render(PARSER.parse("> [!note]\n> Content\n"));
+ assertThat(rendered).isEqualTo("> [!NOTE]\n> Content\n");
+ }
+
+ @Test
+ public void alertWithMultipleParagraphs() {
+ String input = "> [!NOTE]\n> First paragraph\n>\n> Second paragraph\n";
+ // MarkdownWriter always writes the prefix including trailing space
+ String expected = "> [!NOTE]\n> First paragraph\n> \n> Second paragraph\n";
+ String rendered = RENDERER.render(PARSER.parse(input));
+ assertThat(rendered).isEqualTo(expected);
+ }
+
+ @Test
+ public void customTypeRoundTrip() {
+ 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 input = "> [!INFO]\n> Custom type\n";
+ String rendered = renderer.render(parser.parse(input));
+ assertThat(rendered).isEqualTo(input);
+ }
+
+ @Test
+ public void alertWithList() {
+ String input = "> [!NOTE]\n> Items:\n> \n> - First\n> - Second\n";
+ String rendered = RENDERER.render(PARSER.parse(input));
+ assertThat(rendered).isEqualTo(input);
+ }
+
+ private void assertRoundTrip(String input) {
+ String rendered = RENDERER.render(PARSER.parse(input));
+ assertThat(rendered).isEqualTo(input);
+ }
+}
diff --git a/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsSpecTest.java b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsSpecTest.java
new file mode 100644
index 00000000..8155d800
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsSpecTest.java
@@ -0,0 +1,44 @@
+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.commonmark.testutil.example.Example;
+import org.commonmark.testutil.example.ExampleReader;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.Parameter;
+import org.junit.jupiter.params.ParameterizedClass;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.net.URL;
+import java.util.List;
+import java.util.Set;
+
+@ParameterizedClass
+@MethodSource("data")
+public class AlertsSpecTest extends RenderingTestCase {
+
+ private static final Set EXTENSIONS = Set.of(AlertsExtension.create());
+ private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build();
+ // Use softbreak(" ") to match GitHub's rendering for easier comparison with GitHub API output.
+ private static final HtmlRenderer RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).softbreak(" \n").build();
+
+ @Parameter
+ Example example;
+
+ static List data() {
+ URL spec = AlertsSpecTest.class.getResource("/alerts-spec.txt");
+ return ExampleReader.readExamples(spec, "alert");
+ }
+
+ @Test
+ public void testHtmlRendering() {
+ assertRendering(example.getSource(), example.getHtml());
+ }
+
+ @Override
+ protected String render(String source) {
+ return RENDERER.render(PARSER.parse(source));
+ }
+}
\ No newline at end of file
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..c46c532f
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsTest.java
@@ -0,0 +1,140 @@
+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.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class AlertsTest {
+
+ private static final Set EXTENSIONS = Set.of(AlertsExtension.create());
+ private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build();
+
+ // Custom types
+
+ @Test
+ public void customType() {
+ 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();
+
+ assertThat(renderer.render(parser.parse("> [!INFO]\n> Custom alert"))).isEqualTo(
+ "\n" +
+ "
Information
\n" +
+ "
Custom alert
\n" +
+ "
\n");
+ }
+
+ @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();
+
+ assertThat(renderer.render(parser.parse("> [!INFO]\n> Info content\n\n> [!SUCCESS]\n> Success content\n\n> [!DANGER]\n> Danger content"))).isEqualTo(
+ "\n" +
+ "
Information
\n" +
+ "
Info content
\n" +
+ "
\n" +
+ "\n" +
+ "
Success!
\n" +
+ "
Success content
\n" +
+ "
\n" +
+ "\n" +
+ "
Danger!
\n" +
+ "
Danger content
\n" +
+ "
\n");
+ }
+
+ @Test
+ public void standardTypesWithCustomConfigured() {
+ 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();
+
+ assertThat(renderer.render(parser.parse("> [!NOTE]\n> Standard type"))).isEqualTo(
+ "\n" +
+ "
Note
\n" +
+ "
Standard type
\n" +
+ "
\n");
+ }
+
+ @Test
+ public void overrideStandardTypeTitle() {
+ Extension extension = AlertsExtension.builder()
+ .addCustomType("NOTE", "Nota")
+ .build();
+
+ Parser parser = Parser.builder().extensions(Set.of(extension)).build();
+ HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
+
+ assertThat(renderer.render(parser.parse("> [!NOTE]\n> Localized title"))).isEqualTo(
+ "\n" +
+ "
Nota
\n" +
+ "
Localized title
\n" +
+ "
\n");
+ }
+
+ // Custom type validation
+
+ @Test
+ public void customTypeMustBeUppercase() {
+ assertThrows(IllegalArgumentException.class, () ->
+ AlertsExtension.builder().addCustomType("info", "Information").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());
+ }
+
+ // AST
+
+ @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");
+ }
+
+ @Test
+ public void customTypeParsedAsAlertNode() {
+ 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.getType()).isEqualTo("INFO");
+ }
+
+}
\ No newline at end of file
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/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec-template.md b/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec-template.md
new file mode 100644
index 00000000..9c1cf117
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec-template.md
@@ -0,0 +1,280 @@
+# Alerts
+
+## Standard types
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!TIP]
+> This is a tip
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!IMPORTANT]
+> This is important
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!WARNING]
+> This is a warning
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!CAUTION]
+> This is a caution
+````````````````````````````````
+
+## Case insensitivity
+
+Alert type matching is case-insensitive.
+
+```````````````````````````````` example alert
+> [!note]
+> Content
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!Note]
+> Content
+````````````````````````````````
+
+## Alert content
+
+Marker alone in first paragraph, blank line, then content:
+
+```````````````````````````````` example alert
+> [!NOTE]
+>
+> Content
+````````````````````````````````
+
+Multiple paragraphs:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First paragraph
+>
+> Second paragraph
+````````````````````````````````
+
+Inline formatting:
+
+```````````````````````````````` example alert
+> [!TIP]
+> This is **bold** and *italic*
+````````````````````````````````
+
+Code block inside alert:
+
+```````````````````````````````` example alert
+> [!TIP]
+> Code:
+>
+> function() { }
+>
+> End
+````````````````````````````````
+
+List inside alert:
+
+```````````````````````````````` example alert
+> [!IMPORTANT]
+> Items:
+> - First item
+> - Second item
+````````````````````````````````
+
+Links inside alert:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> Check out [this link](https://example.com) for more info
+````````````````````````````````
+
+Heading inside alert:
+
+```````````````````````````````` example alert
+> [!IMPORTANT]
+> ## Heading
+> Content below heading
+````````````````````````````````
+
+Empty lines in middle of alert:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First
+>
+>
+> After empty lines
+````````````````````````````````
+
+## Not an alert
+
+Text after marker on the same line:
+
+```````````````````````````````` example alert
+> [!NOTE] Some text
+````````````````````````````````
+
+Unknown type:
+
+```````````````````````````````` example alert
+> [!INVALID]
+> Some text
+````````````````````````````````
+
+Unconfigured custom type is not an alert:
+
+```````````````````````````````` example alert
+> [!INFO]
+> Should be blockquote
+````````````````````````````````
+
+Marker with no content:
+
+```````````````````````````````` example alert
+> [!NOTE]
+````````````````````````````````
+
+Whitespace-only content after marker:
+
+```````````````````````````````` example alert
+> [!TIP]
+>
+>
+````````````````````````````````
+
+Extra space inside marker:
+
+```````````````````````````````` example alert
+> [! NOTE]
+> Should be blockquote
+````````````````````````````````
+
+Missing brackets:
+
+```````````````````````````````` example alert
+> !NOTE
+> Should be blockquote
+````````````````````````````````
+
+Missing exclamation mark:
+
+```````````````````````````````` example alert
+> [NOTE]
+> Should be blockquote
+````````````````````````````````
+
+Regular blockquote is not affected:
+
+```````````````````````````````` example alert
+> This is a regular blockquote
+````````````````````````````````
+
+## Boundaries
+
+Trailing spaces after marker:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+````````````````````````````````
+
+Trailing tabs after marker:
+
+```````````````````````````````` example alert
+> [!WARNING]→→
+> Be careful
+````````````````````````````````
+
+Leading spaces before blockquote marker:
+
+```````````````````````````````` example alert
+ > [!IMPORTANT]
+ > Content
+````````````````````````````````
+
+Blank line after marker ends the blockquote (not an alert):
+
+```````````````````````````````` example alert
+> [!NOTE]
+
+Some text
+````````````````````````````````
+
+Alert followed by blockquote:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is an alert
+
+> This is a blockquote
+````````````````````````````````
+
+Adjacent alerts:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First alert
+
+> [!WARNING]
+> Second alert
+````````````````````````````````
+
+## Nesting and containers
+
+Nested alert inside alert renders as blockquote:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+>> [!WARNING]
+>> Nested content
+````````````````````````````````
+
+Nested blockquote inside alert:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+>> Nested blockquote
+````````````````````````````````
+
+Alert inside list item stays as blockquote:
+
+```````````````````````````````` example alert
+- > [!NOTE]
+ > Test
+````````````````````````````````
+
+Alert marker in content is treated as text:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+> [!WARNING]
+> This is still part of the note
+````````````````````````````````
+
+## Continuation and interruption
+
+Lazy continuation:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First line
+Lazy continuation
+> Continues alert
+````````````````````````````````
+
+Alert type after regular blockquote content is not an alert:
+
+```````````````````````````````` example alert
+> Regular blockquote
+> [!NOTE]
+> More text
+````````````````````````````````
\ No newline at end of file
diff --git a/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec.txt b/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec.txt
new file mode 100644
index 00000000..6f041fee
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec.txt
@@ -0,0 +1,492 @@
+# Alerts
+
+Expectations verified against GitHub Markdown API (gh api markdown -f mode=gfm).
+Our HTML omits GitHub's SVG icons and uses a `data-alert-type` attribute instead.
+
+## Standard types
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+.
+
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!TIP]
+> This is a tip
+.
+
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!IMPORTANT]
+> This is important
+.
+
+
Important
+
This is important
+
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!WARNING]
+> This is a warning
+.
+
+
Warning
+
This is a warning
+
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!CAUTION]
+> This is a caution
+.
+
+
Caution
+
This is a caution
+
+````````````````````````````````
+
+## Case insensitivity
+
+Alert type matching is case-insensitive.
+
+```````````````````````````````` example alert
+> [!note]
+> Content
+.
+
+````````````````````````````````
+
+```````````````````````````````` example alert
+> [!Note]
+> Content
+.
+
+````````````````````````````````
+
+## Alert content
+
+Marker alone in first paragraph, blank line, then content:
+
+```````````````````````````````` example alert
+> [!NOTE]
+>
+> Content
+.
+
+````````````````````````````````
+
+Multiple paragraphs:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First paragraph
+>
+> Second paragraph
+.
+
+
Note
+
First paragraph
+
Second paragraph
+
+````````````````````````````````
+
+Inline formatting:
+
+```````````````````````````````` example alert
+> [!TIP]
+> This is **bold** and *italic*
+.
+
+
Tip
+
This is bold and italic
+
+````````````````````````````````
+
+Code block inside alert:
+
+```````````````````````````````` example alert
+> [!TIP]
+> Code:
+>
+> function() { }
+>
+> End
+.
+
+
Tip
+
Code:
+
function() { }
+
+
End
+
+````````````````````````````````
+
+List inside alert:
+
+```````````````````````````````` example alert
+> [!IMPORTANT]
+> Items:
+> - First item
+> - Second item
+.
+
+
Important
+
Items:
+
+First item
+Second item
+
+
+````````````````````````````````
+
+Links inside alert:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> Check out [this link](https://example.com) for more info
+.
+
+````````````````````````````````
+
+Heading inside alert:
+
+```````````````````````````````` example alert
+> [!IMPORTANT]
+> ## Heading
+> Content below heading
+.
+
+
Important
+
Heading
+
Content below heading
+
+````````````````````````````````
+
+Empty lines in middle of alert:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First
+>
+>
+> After empty lines
+.
+
+
Note
+
First
+
After empty lines
+
+````````````````````````````````
+
+## Not an alert
+
+Text after marker on the same line:
+
+```````````````````````````````` example alert
+> [!NOTE] Some text
+.
+
+[!NOTE] Some text
+
+````````````````````````````````
+
+Unknown type:
+
+```````````````````````````````` example alert
+> [!INVALID]
+> Some text
+.
+
+[!INVALID]
+Some text
+
+````````````````````````````````
+
+Unconfigured custom type is not an alert:
+
+```````````````````````````````` example alert
+> [!INFO]
+> Should be blockquote
+.
+
+[!INFO]
+Should be blockquote
+
+````````````````````````````````
+
+Marker with no content:
+
+```````````````````````````````` example alert
+> [!NOTE]
+.
+
+[!NOTE]
+
+````````````````````````````````
+
+Whitespace-only content after marker:
+
+```````````````````````````````` example alert
+> [!TIP]
+>
+>
+.
+
+[!TIP]
+
+````````````````````````````````
+
+Extra space inside marker:
+
+```````````````````````````````` example alert
+> [! NOTE]
+> Should be blockquote
+.
+
+[! NOTE]
+Should be blockquote
+
+````````````````````````````````
+
+Missing brackets:
+
+```````````````````````````````` example alert
+> !NOTE
+> Should be blockquote
+.
+
+!NOTE
+Should be blockquote
+
+````````````````````````````````
+
+Missing exclamation mark:
+
+```````````````````````````````` example alert
+> [NOTE]
+> Should be blockquote
+.
+
+[NOTE]
+Should be blockquote
+
+````````````````````````````````
+
+Regular blockquote is not affected:
+
+```````````````````````````````` example alert
+> This is a regular blockquote
+.
+
+This is a regular blockquote
+
+````````````````````````````````
+
+## Boundaries
+
+Trailing spaces after marker:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+.
+
+````````````````````````````````
+
+Trailing tabs after marker:
+
+```````````````````````````````` example alert
+> [!WARNING]→→
+> Be careful
+.
+
+````````````````````````````````
+
+Leading spaces before blockquote marker:
+
+```````````````````````````````` example alert
+ > [!IMPORTANT]
+ > Content
+.
+
+````````````````````````````````
+
+Blank line after marker ends the blockquote (not an alert):
+
+```````````````````````````````` example alert
+> [!NOTE]
+
+Some text
+.
+
+[!NOTE]
+
+Some text
+````````````````````````````````
+
+Alert followed by blockquote:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is an alert
+
+> This is a blockquote
+.
+
+
Note
+
This is an alert
+
+
+This is a blockquote
+
+````````````````````````````````
+
+Adjacent alerts:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First alert
+
+> [!WARNING]
+> Second alert
+.
+
+
+````````````````````````````````
+
+## Nesting and containers
+
+Nested alert inside alert renders as blockquote:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+>> [!WARNING]
+>> Nested content
+.
+
+
Note
+
This is a note
+
+[!WARNING]
+Nested content
+
+
+````````````````````````````````
+
+Nested blockquote inside alert:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+>> Nested blockquote
+.
+
+
Note
+
This is a note
+
+Nested blockquote
+
+
+````````````````````````````````
+
+Alert inside list item stays as blockquote:
+
+```````````````````````````````` example alert
+- > [!NOTE]
+ > Test
+.
+
+````````````````````````````````
+
+Alert marker in content is treated as text:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> This is a note
+> [!WARNING]
+> This is still part of the note
+.
+
+
Note
+
This is a note
+[!WARNING]
+This is still part of the note
+
+````````````````````````````````
+
+## Continuation and interruption
+
+Lazy continuation:
+
+```````````````````````````````` example alert
+> [!NOTE]
+> First line
+Lazy continuation
+> Continues alert
+.
+
+
Note
+
First line
+Lazy continuation
+Continues alert
+
+````````````````````````````````
+
+Alert type after regular blockquote content is not an alert:
+
+```````````````````````````````` example alert
+> Regular blockquote
+> [!NOTE]
+> More text
+.
+
+Regular blockquote
+[!NOTE]
+More text
+
+````````````````````````````````
diff --git a/commonmark-ext-gfm-alerts/src/test/resources/generate-alerts-spec.java b/commonmark-ext-gfm-alerts/src/test/resources/generate-alerts-spec.java
new file mode 100644
index 00000000..06192f10
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/test/resources/generate-alerts-spec.java
@@ -0,0 +1,111 @@
+///usr/bin/env jbang "$0" "$@" ; exit $?
+
+// Generates alerts-spec.txt from alerts-spec-template.md by rendering each example
+// through the GitHub Markdown API and inserting the normalized HTML expectation.
+//
+// Prerequisites: gh CLI installed and authenticated (gh auth login)
+// Usage: cd commonmark-ext-gfm-alerts/src/test/resources && jbang generate-alerts-spec.java
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+class GenerateAlertsSpec {
+
+ private static final String FENCE = "````````````````````````````````";
+ private static final String EXAMPLE_OPEN = FENCE + " example alert";
+
+ public static void main(String[] args) throws Exception {
+ var templatePath = Path.of("alerts-spec-template.md");
+ if (!Files.exists(templatePath)) {
+ System.err.println("Run from the directory containing alerts-spec-template.md");
+ System.exit(1);
+ }
+
+ var lines = Files.readAllLines(templatePath);
+ var output = new ArrayList();
+ var header = "Expectations verified against GitHub Markdown API (gh api markdown -f mode=gfm).\n" +
+ "Our HTML omits GitHub's SVG icons and uses a `data-alert-type` attribute instead.";
+
+ int exampleCount = 0;
+ int i = 0;
+ while (i < lines.size()) {
+ var line = lines.get(i);
+
+ // Insert header after the first heading
+ if (i == 0 && line.startsWith("# ")) {
+ output.add(line);
+ output.add("");
+ output.add(header);
+ i++;
+ continue;
+ }
+
+ if (line.equals(EXAMPLE_OPEN)) {
+ // Collect source lines until closing fence
+ output.add(line);
+ i++;
+ var sourceLines = new ArrayList();
+ while (i < lines.size() && !lines.get(i).equals(FENCE)) {
+ sourceLines.add(lines.get(i));
+ output.add(lines.get(i));
+ i++;
+ }
+
+ // Render via GitHub API (→ represents tabs in the spec format)
+ var source = String.join("\n", sourceLines).replace("\u2192", "\t");
+ exampleCount++;
+ System.out.printf("%d: %s%n", exampleCount,
+ source.substring(0, Math.min(40, source.length())).replace("\n", "\\n"));
+
+ var ghHtml = normalizeHtml(renderViaGh(source));
+
+ // Insert separator and HTML expectation
+ output.add(".");
+ output.add(ghHtml);
+ output.add(FENCE);
+ i++; // skip closing fence from template
+ } else {
+ output.add(line);
+ i++;
+ }
+ }
+
+ var specPath = Path.of("alerts-spec.txt");
+ Files.writeString(specPath, String.join("\n", output) + "\n");
+ System.out.println("Done — " + exampleCount + " examples written to alerts-spec.txt");
+ }
+
+ static String renderViaGh(String markdown) throws Exception {
+ var process = new ProcessBuilder("gh", "api", "markdown", "-f", "mode=gfm", "-f", "text=" + markdown)
+ .redirectErrorStream(true)
+ .start();
+ var output = new String(process.getInputStream().readAllBytes());
+ if (process.waitFor() != 0) {
+ throw new RuntimeException("gh api failed: " + output);
+ }
+ return output;
+ }
+
+ // Normalize GitHub API HTML to match our renderer output.
+ static String normalizeHtml(String html) {
+ // Strip GitHub-specific elements and attributes
+ html = Pattern.compile("]*>.*? ", Pattern.DOTALL).matcher(html).replaceAll("");
+ html = html.replaceAll(" (dir=\"auto\"|rel=\"nofollow\"|class=\"notranslate\")", "");
+ // Add data-alert-type and insert newlines to match our renderer's formatting
+ html = Pattern.compile("class=\"markdown-alert markdown-alert-(\\w+)\"")
+ .matcher(html)
+ .replaceAll("class=\"markdown-alert markdown-alert-$1\" data-alert-type=\"$1\"");
+ html = Pattern.compile("(data-alert-type=\"\\w+\">)(
", "
\n");
+ return html.replace("\r\n", "\n").lines()
+ .map(String::stripTrailing)
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("")
+ .strip();
+ }
+}
\ No newline at end of file
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