From d2df7f6763ddcf8b87a08c813cdecc5a410856f9 Mon Sep 17 00:00:00 2001 From: Ryan DeStefano <67760716+rdestefa@users.noreply.github.com> Date: Sun, 3 May 2026 03:51:21 -0700 Subject: [PATCH 1/6] Add Support for Custom Alert Titles --- CHANGELOG.md | 19 +- README.md | 19 +- commonmark-ext-gfm-alerts/README.md | 52 +++- .../org/commonmark/ext/gfm/alerts/Alert.java | 2 + .../commonmark/ext/gfm/alerts/AlertTitle.java | 25 ++ .../ext/gfm/alerts/AlertsExtension.java | 63 +++- .../gfm/alerts/internal/AlertBlockParser.java | 243 +++++++++++++++ .../internal/AlertHtmlNodeRenderer.java | 14 +- .../internal/AlertMarkdownNodeRenderer.java | 15 +- .../alerts/internal/AlertPostProcessor.java | 111 ------- .../alerts/AlertsMarkdownRendererTest.java | 55 +++- .../ext/gfm/alerts/AlertsSpecTest.java | 9 +- .../commonmark/ext/gfm/alerts/AlertsTest.java | 286 +++++++++++++++++- 13 files changed, 767 insertions(+), 146 deletions(-) create mode 100644 commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/AlertTitle.java create mode 100644 commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertBlockParser.java delete mode 100644 commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertPostProcessor.java diff --git a/CHANGELOG.md b/CHANGELOG.md index afce7250..254826f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,24 @@ with the exception that 0.x versions can break between minor versions. ## [Unreleased] ### Added -- Allow customizing HTML attributes for alert title `
` tag via `AttributeProvider` - Support rendering GFM task list items to Markdown - Support rendering YAML front matter to Markdown +- Alerts + - Allow customizing HTML attributes for alert title `
` tag via `AttributeProvider` + - New configuration for `AlertsExtension` to allow authors to provide custom + titles per alert. See the + [custom titles section of the alerts README](./commonmark-ext-gfm-alerts/README.md#custom-alert-titles) + for more information. + - New configuration for `AlertsExtension` to allow alerts to be nested within + other blocks (including other alerts). See + [this section of the alerts README](./commonmark-ext-gfm-alerts/README.md#nesting-alerts) + for more information. ## [0.28.0] - 2026-03-31 ### Added - New extension for alerts (aka callouts/admonitions) - Syntax: - ``` + ```markdown > [!NOTE] > The text of the note. ``` @@ -104,9 +113,9 @@ with the exception that 0.x versions can break between minor versions. ### Added - New extension for footnotes! - Syntax: - ``` + ```markdown Main text[^1] - + [^1]: Additional text in a footnote ``` - Inline footnotes like `^[inline footnote]` are also supported when enabled @@ -271,7 +280,7 @@ with the exception that 0.x versions can break between minor versions. - Use class `ImageAttributesExtension` in artifact `commonmark-ext-image-attributes` - Extension for task lists (GitHub-style), thanks @dohertyfjatl - Syntax: - ``` + ```markdown - [x] task #1 - [ ] task #2 ``` diff --git a/README.md b/README.md index 7bf598ba..1f00e0f0 100644 --- a/README.md +++ b/README.md @@ -339,21 +339,22 @@ Use class `TablesExtension` in artifact `commonmark-ext-gfm-tables`. Adds support for GitHub-style alerts (also known as callouts or admonitions) as described [here](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts), e.g.: -``` +```markdown > [!NOTE] > The text of the note. ``` As types you can use NOTE, TIP, IMPORTANT, WARNING, CAUTION; or configure the extension to add additional ones. -Use class `AlertsExtension` in artifact `commonmark-ext-gfm-alerts`. +Use class `AlertsExtension` in artifact `commonmark-ext-gfm-alerts`. See the +[`AlertsExtension` README](./commonmark-ext-gfm-alerts/README.md) for more information. ### Footnotes Enables footnotes like in [GitHub](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#footnotes) or [Pandoc](https://pandoc.org/MANUAL.html#footnotes): -``` +```markdown Main text[^1] [^1]: Additional text in a footnote @@ -370,7 +371,7 @@ is based on the text of the heading. `# Heading` will be rendered as: -``` +```html
```
@@ -436,12 +437,12 @@ whitespace character or the letter `x` in lowercase or uppercase, then a right b
whitespace before any other content.
For example:
-```
+```markdown
- [ ] task #1
- [x] task #2
```
will be rendered as:
-```
+```html
+ * When present, an {@code AlertTitle} is always the first child of an {@link Alert}. + * Its own children are the parsed inline nodes of the title (i.e., the text after + * the {@code [!TYPE]} marker on the same line). For example, in + * + *
{@code
+ * > [!NOTE] Custom _title_
+ * > Body text
+ * }
+ *
+ * the {@code AlertTitle} contains a {@code Text} node ({@code "Custom "}) followed
+ * by an {@code Emphasis} node wrapping {@code "title"}.
+ *
+ * @see AlertsExtension.Builder#allowCustomTitles()
+ * @see AlertsExtension.Builder#disallowCustomTitles()
+ */
+public class AlertTitle extends CustomNode {
+}
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
index b9a44985..a3c1f3b3 100644
--- 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
@@ -1,7 +1,7 @@
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.AlertBlockParser;
import org.commonmark.ext.gfm.alerts.internal.AlertHtmlNodeRenderer;
import org.commonmark.ext.gfm.alerts.internal.AlertMarkdownNodeRenderer;
import org.commonmark.parser.Parser;
@@ -24,6 +24,17 @@
* ({@link org.commonmark.parser.Parser.Builder#extensions(Iterable)},
* {@link HtmlRenderer.Builder#extensions(Iterable)}).
* Parsed alerts become {@link Alert} blocks.
+ *
+ * The {@link #create() default configuration} of this extension will match GFM
+ * exactly, with the following exceptions:
+ *
+ * - Alert markers take precedence over link reference definitions.
+ * - Lazy continuation is not allowed between the marker and the body text. Example:
+ *
+ * {@code
+ * > [!NOTE]
+ * Lazy body text will be parsed as a new paragraph
+ * }
*/
public class AlertsExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension,
MarkdownRenderer.MarkdownRendererExtension {
@@ -31,9 +42,13 @@ public class AlertsExtension implements Parser.ParserExtension, HtmlRenderer.Htm
static final Set
+ * Note that even with this enabled, {@link Parser.Builder#maxOpenBlockParsers(int)}
+ * will be respected.
+ * @return {@code this}
+ */
+ public Builder allowNestedAlerts() {
+ nestedAlertsAllowed = true;
+ return this;
+ }
+
+ /**
+ * Prevents alerts from being parsed within blocks other than {@code Document}
+ * (the root). If an alert appears within another block, it will be parsed as
+ * a regular {@code BlockQuote}.
+ * @return {@code this}
+ */
+ public Builder disallowNestedAlerts() {
+ nestedAlertsAllowed = false;
+ return this;
+ }
+
/**
* @return a configured {@link Extension}
*/
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..d062a1a3
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertBlockParser.java
@@ -0,0 +1,243 @@
+package org.commonmark.ext.gfm.alerts.internal;
+
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.commonmark.ext.gfm.alerts.Alert;
+import org.commonmark.ext.gfm.alerts.AlertTitle;
+import org.commonmark.node.Block;
+import org.commonmark.node.BlockQuote;
+import org.commonmark.node.Document;
+import org.commonmark.node.Paragraph;
+import org.commonmark.parser.InlineParser;
+import org.commonmark.parser.SourceLine;
+import org.commonmark.parser.SourceLines;
+import org.commonmark.parser.block.AbstractBlockParser;
+import org.commonmark.parser.block.AbstractBlockParserFactory;
+import org.commonmark.parser.block.BlockContinue;
+import org.commonmark.parser.block.BlockStart;
+import org.commonmark.parser.block.MatchedBlockParser;
+import org.commonmark.parser.block.ParserState;
+import org.commonmark.text.Characters;
+
+public class AlertBlockParser extends AbstractBlockParser {
+
+ private static final Pattern ALERT_PATTERN_NO_CUSTOM_TITLE = Pattern.compile("^\\[!([a-zA-Z]+)]\\s*$");
+ private static final Pattern ALERT_PATTERN_CUSTOM_TITLE = Pattern.compile("^\\[!([a-zA-Z]+)](.*)$");
+
+ private final Alert block;
+ private final String typeOriginalCase;
+ private final String titleContent;
+
+ private AlertBlockParser(String type, String typeOriginalCase, String titleContent) {
+ this.block = new Alert(type);
+ this.typeOriginalCase = typeOriginalCase;
+ this.titleContent = titleContent;
+ }
+
+ @Override
+ public Block getBlock() {
+ return block;
+ }
+
+ @Override
+ public boolean isContainer() {
+ return true;
+ }
+
+ @Override
+ public boolean canContain(Block childBlock) {
+ return true;
+ }
+
+ @Override
+ public BlockContinue tryContinue(ParserState state) {
+ /*
+ * Same continuation rule as a block quote: line must start with '>'
+ * (with up to 3 leading spaces, optional space after '>')
+ */
+ var line = state.getLine().getContent();
+ int nextNonSpace = state.getNextNonSpaceIndex();
+ if (state.getIndent() >= 4 // Parsing.CODE_BLOCK_INDENT
+ || nextNonSpace >= line.length()
+ || line.charAt(nextNonSpace) != '>') {
+ return BlockContinue.none();
+ }
+
+ int newColumn = state.getColumn() + state.getIndent() + 1;
+ if (Characters.isSpaceOrTab(line, nextNonSpace + 1)) {
+ newColumn++;
+ }
+
+ return BlockContinue.atColumn(newColumn);
+ }
+
+ @Override
+ public void parseInlines(InlineParser inlineParser) {
+ // Determine if there is any non-title body content.
+ if (block.getFirstChild() == null) {
+ /*
+ * Replace the Alert with a BlockQuote whose only paragraph contains
+ * the original first line text.
+ */
+ demoteToBlockQuote(inlineParser);
+ return;
+ }
+
+ if (titleContent.isEmpty()) {
+ return;
+ }
+
+ /*
+ * Inline-parse the title in its own scope so delimiters are isolated
+ * from the body text. For example:
+ *
+ * > [!NOTE] 2*2 = 4
+ * > But 3*3 = 9
+ */
+ var titleNode = new AlertTitle();
+ inlineParser.parse(SourceLines.of(SourceLine.of(titleContent, null)), titleNode);
+
+ // Body blocks were attached as children during block parsing. Prepend the title.
+ block.prependChild(titleNode);
+ }
+
+ private void demoteToBlockQuote(InlineParser inlineParser) {
+ var bq = new BlockQuote();
+ bq.setSourceSpans(block.getSourceSpans());
+ var p = new Paragraph();
+
+ // Build the literal text including the alert marker and title.
+ var literal = "[!" + typeOriginalCase + "]";
+ if (!titleContent.isEmpty()) {
+ /*
+ * This may not preserve the original number of spaces between the
+ * alert marker and title (e.g., if there were 0 or 2+ spaces).
+ */
+ literal += " " + titleContent;
+ }
+
+ // Parse the inlines of the full content (alert marker + title)
+ inlineParser.parse(SourceLines.of(SourceLine.of(literal, null)), p);
+ bq.appendChild(p);
+ block.insertAfter(bq);
+ block.unlink();
+ }
+
+ public static class Factory extends AbstractBlockParserFactory {
+
+ private final Set
+ * This test should only be used for the default configuration of
+ * {@link AlertsExtension}. Other configurations cause deviation from GFM.
+ */
@ParameterizedClass
@MethodSource("data")
public class AlertsSpecTest extends RenderingTestCase {
@@ -41,4 +48,4 @@ public void testHtmlRendering() {
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
index c46c532f..f812d3f8 100644
--- 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
@@ -4,6 +4,7 @@
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;
@@ -11,10 +12,28 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
-public class AlertsTest {
+public class AlertsTest extends RenderingTestCase {
private static final Set
Custom title
\n" + + "Note with a custom title
\n" + + "Custom title
\n" + + "Note with a custom title
\n" + + "Custom title
\n" + + "Note with a custom title
\n" + + "Custom title\\
\n" + + "Note with a custom title
\n" + + "Custom title
\n" + + "Note with a custom title
\n" + + "Tip
\n" + + "Body text
\n" + + "Custom title with formatting
\n" + + "Note with a custom title
\n" + + "See docs or run()
Note with a custom title
\n" + + "Custom title
\n" + + "Note with a custom title
\n" + + "Custom _title with **opening `delimiters
\n" + + "Note` with** closing delimiters_
\n" + + "## Custom title looks like an ATX heading
\n" + + "But it's not
\n" + + "\n" + + "\n"); + } + + @Test + public void customTitleNoBodyNoSpace() { + assertRenderingCustomTitles("> [!NOTE] Custom _title_\n> \n>\n>", + "[!NOTE] Custom title
\n" + + "
\n" + + "\n"); + } + + @Test + public void onlyTrailingWhitespaceIsNotCustomTitle() { + assertRenderingCustomTitles("> [!NOTE] \n> Body text", + "[!NOTE] Custom title
\n" + + "
Note
\n" + + "Body text
\n" + + "\n" + + "\n" + + "[!NOTE]
\n" + + "
Body text
\n"); + } + + @Test + public void noLazyContinuationAfterTitle() { + assertRenderingCustomTitles("> [!NOTE] Custom title\nBody text", + "\n" + + "\n" + + "[!NOTE] Custom title
\n" + + "
Body text
\n"); + } + + // Alert markers take precedence over link reference definitions + + @Test + public void alertTakesPrecedence() { + assertRenderingCustomTitles("> [!NOTE]: https://example.com\n> Body text\n\n[!NOTE]", + ": https://example.com
\n" + + "Body text
\n" + + "[!NOTE]
\n"); + } + + @Test + public void alertTakesPrecedenceBefore() { + assertRenderingCustomTitles("> [!NOTE]\n> Body text\n\n[!NOTE]: https://example.com", + "Note
\n" + + "Body text
\n" + + "Note
\n" + + "Body text
\n" + + "Tip
\n" + + "Body
\n" + + "\n" + + "\n" + + "[!TIP]\n" + + "Nested body
\n" + + "
Tip
\n" + + "Body
\n" + + "\n" + + "\n" + + "[!TIP]\n" + + "Nested body
\n" + + "
Tip
", + "Tip body
", + "Important
", + "Important body
", + "Note
", + "Nested body
", + "Ordered list body
", + "Caution
", + "Deeply nested body
", + "Tip
\n" + + "Tip
\n" + "Body text
\n" + "Note
\n" + + "Body text
\n" + + "- * Note that even with this enabled, {@link Parser.Builder#maxOpenBlockParsers(int)} - * will be respected. - * @return {@code this} - */ - public Builder allowNestedAlerts() { - nestedAlertsAllowed = true; - return this; - } - - /** - * Prevents alerts from being parsed within blocks other than {@code Document} - * (the root). If an alert appears within another block, it will be parsed as + * When disallowed, if an alert appears within another block, it will be parsed as * a regular {@code BlockQuote}. + *
+ * Note that even when this is allowed, {@link Parser.Builder#maxOpenBlockParsers(int)} + * will be respected. + * @param allow Whether to allow or disallow parsing alerts within non-root blocks. * @return {@code this} */ - public Builder disallowNestedAlerts() { - nestedAlertsAllowed = false; + public Builder allowNestedAlerts(boolean allow) { + nestedAlertsAllowed = allow; return 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 index d062a1a3..35607307 100644 --- 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 @@ -59,14 +59,14 @@ public BlockContinue tryContinue(ParserState state) { * (with up to 3 leading spaces, optional space after '>') */ var line = state.getLine().getContent(); - int nextNonSpace = state.getNextNonSpaceIndex(); + var nextNonSpace = state.getNextNonSpaceIndex(); if (state.getIndent() >= 4 // Parsing.CODE_BLOCK_INDENT || nextNonSpace >= line.length() || line.charAt(nextNonSpace) != '>') { return BlockContinue.none(); } - int newColumn = state.getColumn() + state.getIndent() + 1; + var newColumn = state.getColumn() + state.getIndent() + 1; if (Characters.isSpaceOrTab(line, nextNonSpace + 1)) { newColumn++; } @@ -150,7 +150,7 @@ public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockPar } var line = state.getLine().getContent(); - int nextNonSpace = state.getNextNonSpaceIndex(); + var nextNonSpace = state.getNextNonSpaceIndex(); // Case A: Fresh start. Line begins with '>'. if (nextNonSpace < line.length() && line.charAt(nextNonSpace) == '>') { @@ -205,24 +205,20 @@ private BlockStart tryStartFresh(CharSequence line, int nextNonSpace, ParserStat afterGt = state.getIndex(); } - Matcher matcher; - if (customTitlesAllowed) { - matcher = ALERT_PATTERN_CUSTOM_TITLE.matcher(line.subSequence(afterGt, line.length())); - } else { - matcher = ALERT_PATTERN_NO_CUSTOM_TITLE.matcher(line.subSequence(afterGt, line.length())); - } + var pattern = customTitlesAllowed ? ALERT_PATTERN_CUSTOM_TITLE : ALERT_PATTERN_NO_CUSTOM_TITLE; + var matcher = pattern.matcher(line.subSequence(afterGt, line.length())); if (!matcher.matches()) { return BlockStart.none(); } - String typeOriginalCase = matcher.group(1); - String type = typeOriginalCase.toUpperCase(Locale.ROOT); + var typeOriginalCase = matcher.group(1); + var type = typeOriginalCase.toUpperCase(Locale.ROOT); if (!allowedTypes.contains(type)) { return BlockStart.none(); } - String titleContent = ""; + var titleContent = ""; if (customTitlesAllowed) { titleContent = matcher.group(2).replaceFirst("^[ \\t]+", "").stripTrailing(); } 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 index a7d84469..062f637e 100644 --- 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 @@ -113,7 +113,7 @@ private boolean isCommentOnlyTitle(Node title) { } private boolean isHtmlComment(HtmlInline htmlInline) { - String literal = htmlInline.getLiteral(); + var literal = htmlInline.getLiteral(); if (literal == null || !literal.startsWith(" + * > [!NOTE] + * + * + * > [!NOTE] Custom title + * } * - Lazy continuation is not allowed between the marker and the body text. Example: * *
{@code
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
index 35607307..fc1cee97 100644
--- 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
@@ -2,7 +2,6 @@
import java.util.Locale;
import java.util.Set;
-import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.commonmark.ext.gfm.alerts.Alert;
@@ -10,7 +9,6 @@
import org.commonmark.node.Block;
import org.commonmark.node.BlockQuote;
import org.commonmark.node.Document;
-import org.commonmark.node.Paragraph;
import org.commonmark.parser.InlineParser;
import org.commonmark.parser.SourceLine;
import org.commonmark.parser.SourceLines;
@@ -28,12 +26,10 @@ public class AlertBlockParser extends AbstractBlockParser {
private static final Pattern ALERT_PATTERN_CUSTOM_TITLE = Pattern.compile("^\\[!([a-zA-Z]+)](.*)$");
private final Alert block;
- private final String typeOriginalCase;
private final String titleContent;
- private AlertBlockParser(String type, String typeOriginalCase, String titleContent) {
+ private AlertBlockParser(String type, String titleContent) {
this.block = new Alert(type);
- this.typeOriginalCase = typeOriginalCase;
this.titleContent = titleContent;
}
@@ -76,16 +72,6 @@ public BlockContinue tryContinue(ParserState state) {
@Override
public void parseInlines(InlineParser inlineParser) {
- // Determine if there is any non-title body content.
- if (block.getFirstChild() == null) {
- /*
- * Replace the Alert with a BlockQuote whose only paragraph contains
- * the original first line text.
- */
- demoteToBlockQuote(inlineParser);
- return;
- }
-
if (titleContent.isEmpty()) {
return;
}
@@ -104,28 +90,6 @@ public void parseInlines(InlineParser inlineParser) {
block.prependChild(titleNode);
}
- private void demoteToBlockQuote(InlineParser inlineParser) {
- var bq = new BlockQuote();
- bq.setSourceSpans(block.getSourceSpans());
- var p = new Paragraph();
-
- // Build the literal text including the alert marker and title.
- var literal = "[!" + typeOriginalCase + "]";
- if (!titleContent.isEmpty()) {
- /*
- * This may not preserve the original number of spaces between the
- * alert marker and title (e.g., if there were 0 or 2+ spaces).
- */
- literal += " " + titleContent;
- }
-
- // Parse the inlines of the full content (alert marker + title)
- inlineParser.parse(SourceLines.of(SourceLine.of(literal, null)), p);
- bq.appendChild(p);
- block.insertAfter(bq);
- block.unlink();
- }
-
public static class Factory extends AbstractBlockParserFactory {
private final Set allowedTypes;
@@ -212,8 +176,7 @@ private BlockStart tryStartFresh(CharSequence line, int nextNonSpace, ParserStat
return BlockStart.none();
}
- var typeOriginalCase = matcher.group(1);
- var type = typeOriginalCase.toUpperCase(Locale.ROOT);
+ var type = matcher.group(1).toUpperCase(Locale.ROOT);
if (!allowedTypes.contains(type)) {
return BlockStart.none();
}
@@ -224,7 +187,7 @@ private BlockStart tryStartFresh(CharSequence line, int nextNonSpace, ParserStat
}
// Consume the rest of the first line.
- var start = BlockStart.of(new AlertBlockParser(type, typeOriginalCase, titleContent)).atIndex(line.length());
+ var start = BlockStart.of(new AlertBlockParser(type, titleContent)).atIndex(line.length());
// If we got here via the promotion path, replace the empty BlockQuote.
var matched = state.getActiveBlockParser().getBlock();
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
index 8259b323..db6f6352 100644
--- 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
@@ -214,19 +214,19 @@ public void customTitleIgnoresBlockContent() {
@Test
public void customTitleNoBody() {
- // Inlines should be parsed as usual after falling back to a block quote.
+ // Alerts with no body are allowed.
assertRenderingCustomTitles("> [!NOTE] Custom _title_\n> \n>\n>",
- "\n" +
- "[!NOTE] Custom title
\n" +
- "
\n");
+ "\n" +
+ "Custom title
\n" +
+ "\n");
}
@Test
public void customTitleNoBodyNoSpace() {
- assertRenderingCustomTitles("> [!NOTE] Custom _title_\n> \n>\n>",
- "\n" +
- "[!NOTE] Custom title
\n" +
- "
\n");
+ assertRenderingCustomTitles("> [!NOTE] Custom _title_",
+ "\n" +
+ "Custom title
\n" +
+ "\n");
}
@Test
@@ -252,18 +252,18 @@ public void onlyHtmlCommentsInTitleUseDefaultTitle() {
@Test
public void noLazyContinuationAfterMarker() {
assertRendering("> [!NOTE]\nBody text",
- "\n" +
- "[!NOTE]
\n" +
- "
\n" +
+ "\n" +
+ "Note
\n" +
+ "\n" +
"Body text
\n");
}
@Test
public void noLazyContinuationAfterTitle() {
assertRenderingCustomTitles("> [!NOTE] Custom title\nBody text",
- "\n" +
- "[!NOTE] Custom title
\n" +
- "
\n" +
+ "\n" +
+ "Custom title
\n" +
+ "\n" +
"Body text
\n");
}
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
index 9c1cf117..e5f88503 100644
--- a/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec-template.md
+++ b/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec-template.md
@@ -134,20 +134,6 @@ Unconfigured custom type is not an alert:
> Should be blockquote
````````````````````````````````
-Marker with no content:
-
-```````````````````````````````` example alert
-> [!NOTE]
-````````````````````````````````
-
-Whitespace-only content after marker:
-
-```````````````````````````````` example alert
-> [!TIP]
->
->
-````````````````````````````````
-
Extra space inside marker:
```````````````````````````````` example alert
@@ -198,14 +184,6 @@ Leading spaces before blockquote marker:
> Content
````````````````````````````````
-Blank line after marker ends the blockquote (not an alert):
-
-```````````````````````````````` example alert
-> [!NOTE]
-
-Some text
-````````````````````````````````
-
Alert followed by blockquote:
```````````````````````````````` example alert
diff --git a/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec.txt b/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec.txt
index 6f041fee..de4b9654 100644
--- a/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec.txt
+++ b/commonmark-ext-gfm-alerts/src/test/resources/alerts-spec.txt
@@ -236,28 +236,6 @@ 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
@@ -342,19 +320,6 @@ Leading spaces before blockquote marker:
````````````````````````````````
-Blank line after marker ends the blockquote (not an alert):
-
-```````````````````````````````` example alert
-> [!NOTE]
-
-Some text
-.
-
-[!NOTE]
-
-Some text
-````````````````````````````````
-
Alert followed by blockquote:
```````````````````````````````` example alert
From def81c30cb434db7c96f0b1aaf244ed37652269b Mon Sep 17 00:00:00 2001
From: Ryan DeStefano <67760716+rdestefa@users.noreply.github.com>
Date: Mon, 1 Jun 2026 03:48:39 -0700
Subject: [PATCH 5/6] Track Source Positions
---
.../gfm/alerts/internal/AlertBlockParser.java | 40 +++-
.../commonmark/ext/gfm/alerts/AlertsTest.java | 192 ++++++++++++++----
2 files changed, 182 insertions(+), 50 deletions(-)
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
index fc1cee97..e7a9f402 100644
--- 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
@@ -1,5 +1,6 @@
package org.commonmark.ext.gfm.alerts.internal;
+import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
@@ -26,11 +27,11 @@ public class AlertBlockParser extends AbstractBlockParser {
private static final Pattern ALERT_PATTERN_CUSTOM_TITLE = Pattern.compile("^\\[!([a-zA-Z]+)](.*)$");
private final Alert block;
- private final String titleContent;
+ private final SourceLine titleLine;
- private AlertBlockParser(String type, String titleContent) {
+ private AlertBlockParser(String type, SourceLine titleLine) {
this.block = new Alert(type);
- this.titleContent = titleContent;
+ this.titleLine = titleLine;
}
@Override
@@ -72,7 +73,7 @@ public BlockContinue tryContinue(ParserState state) {
@Override
public void parseInlines(InlineParser inlineParser) {
- if (titleContent.isEmpty()) {
+ if (titleLine == null || titleLine.getContent().length() == 0) {
return;
}
@@ -84,7 +85,13 @@ public void parseInlines(InlineParser inlineParser) {
* > But 3*3 = 9
*/
var titleNode = new AlertTitle();
- inlineParser.parse(SourceLines.of(SourceLine.of(titleContent, null)), titleNode);
+ inlineParser.parse(SourceLines.of(titleLine), titleNode);
+
+ // Set source spans on the title node from the source line
+ var sourceSpan = titleLine.getSourceSpan();
+ if (sourceSpan != null) {
+ titleNode.setSourceSpans(List.of(sourceSpan));
+ }
// Body blocks were attached as children during block parsing. Prepend the title.
block.prependChild(titleNode);
@@ -181,13 +188,30 @@ private BlockStart tryStartFresh(CharSequence line, int nextNonSpace, ParserStat
return BlockStart.none();
}
- var titleContent = "";
+ SourceLine titleLine = null;
if (customTitlesAllowed) {
- titleContent = matcher.group(2).replaceFirst("^[ \\t]+", "").stripTrailing();
+ var fullSourceLine = state.getLine();
+ var fullContent = fullSourceLine.getContent();
+
+ var groupStart = matcher.start(2);
+ var groupEnd = matcher.end(2);
+ var absStart = afterGt + groupStart;
+ var absEnd = afterGt + groupEnd;
+
+ // Trim leading spaces/tabs
+ while (absStart < absEnd && Characters.isSpaceOrTab(fullContent, absStart)) {
+ absStart++;
+ }
+ // Trim trailing spaces/tabs
+ while (absEnd > absStart && Characters.isSpaceOrTab(fullContent, absEnd - 1)) {
+ absEnd--;
+ }
+
+ titleLine = fullSourceLine.substring(absStart, absEnd);
}
// Consume the rest of the first line.
- var start = BlockStart.of(new AlertBlockParser(type, titleContent)).atIndex(line.length());
+ var start = BlockStart.of(new AlertBlockParser(type, titleLine)).atIndex(line.length());
// If we got here via the promotion path, replace the empty BlockQuote.
var matched = state.getActiveBlockParser().getBlock();
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
index db6f6352..fe01145a 100644
--- 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
@@ -1,12 +1,17 @@
package org.commonmark.ext.gfm.alerts;
import org.commonmark.Extension;
-import org.commonmark.node.Node;
+import org.commonmark.node.Emphasis;
+import org.commonmark.node.SourceSpan;
+import org.commonmark.node.StrongEmphasis;
+import org.commonmark.node.Text;
+import org.commonmark.parser.IncludeSourceSpans;
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.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -15,12 +20,13 @@
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 Parser PARSER = Parser.builder().extensions(EXTENSIONS).includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES).build();
private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).build();
private static final Set EXTENSIONS_CUSTOM_TITLES = Set.of(AlertsExtension.builder().allowCustomTitles(true).build());
private static final Parser PARSER_CUSTOM_TITLES = Parser.builder()
.extensions(EXTENSIONS_CUSTOM_TITLES)
+ .includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES)
.build();
private static final HtmlRenderer HTML_RENDERER_CUSTOM_TITLES = HtmlRenderer.builder()
.extensions(EXTENSIONS_CUSTOM_TITLES)
@@ -39,12 +45,12 @@ private void assertRenderingCustomTitles(String source, String expectedResult) {
@Test
public void customType() {
- Extension extension = AlertsExtension.builder()
+ var 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();
+ var parser = Parser.builder().extensions(Set.of(extension)).build();
+ var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
assertThat(renderer.render(parser.parse("> [!INFO]\n> Custom alert"))).isEqualTo(
"\n" +
@@ -55,14 +61,14 @@ public void customType() {
@Test
public void multipleCustomTypes() {
- Extension extension = AlertsExtension.builder()
+ var 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();
+ var parser = Parser.builder().extensions(Set.of(extension)).build();
+ var 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" +
@@ -81,12 +87,12 @@ public void multipleCustomTypes() {
@Test
public void standardTypesWithCustomConfigured() {
- Extension extension = AlertsExtension.builder()
+ var 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();
+ var parser = Parser.builder().extensions(Set.of(extension)).build();
+ var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
assertThat(renderer.render(parser.parse("> [!NOTE]\n> Standard type"))).isEqualTo(
"\n" +
@@ -97,12 +103,12 @@ public void standardTypesWithCustomConfigured() {
@Test
public void overrideStandardTypeTitle() {
- Extension extension = AlertsExtension.builder()
+ var 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();
+ var parser = Parser.builder().extensions(Set.of(extension)).build();
+ var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
assertThat(renderer.render(parser.parse("> [!NOTE]\n> Localized title"))).isEqualTo(
"\n" +
@@ -111,6 +117,26 @@ public void overrideStandardTypeTitle() {
"\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());
+ }
+
// Custom titles
@Test
@@ -335,9 +361,9 @@ public void noNestedAlertsByDefaultLeadingEmptyLines() {
@Test
public void nestedAlerts() {
- Extension extension = AlertsExtension.builder().allowNestedAlerts(true).build();
- Parser parser = Parser.builder().extensions(Set.of(extension)).build();
- HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
+ var extension = AlertsExtension.builder().allowNestedAlerts(true).build();
+ var parser = Parser.builder().extensions(Set.of(extension)).build();
+ var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
var source = String.join("\n",
"> [!TIP]",
@@ -383,49 +409,131 @@ public void nestedAlerts() {
assertThat(renderer.render(parser.parse(source))).isEqualTo(expected);
}
- // Custom type validation
+ // AST
@Test
- public void customTypeMustBeUppercase() {
- assertThrows(IllegalArgumentException.class, () ->
- AlertsExtension.builder().addCustomType("info", "Information").build());
+ public void alertParsedAsAlertNode() {
+ var document = PARSER.parse("> [!NOTE]\n> This is a note");
+ var firstChild = document.getFirstChild();
+ assertThat(firstChild).isInstanceOf(Alert.class);
+ var alert = (Alert) firstChild;
+ assertThat(alert.getType()).isEqualTo("NOTE");
}
@Test
- public void customTypeMustNotBeEmpty() {
- assertThrows(IllegalArgumentException.class, () ->
- AlertsExtension.builder().addCustomType("", "Title").build());
+ public void customTypeParsedAsAlertNode() {
+ var extension = AlertsExtension.builder()
+ .addCustomType("INFO", "Information")
+ .build();
+
+ var parser = Parser.builder().extensions(Set.of(extension)).build();
+
+ var document = parser.parse("> [!INFO]\n> Custom alert");
+ var alert = (Alert) document.getFirstChild();
+
+ assertThat(alert.getType()).isEqualTo("INFO");
}
+ // Source positions
+
@Test
- public void customTypeTitleMustNotBeEmpty() {
- assertThrows(IllegalArgumentException.class, () ->
- AlertsExtension.builder().addCustomType("INFO", "").build());
+ public void titleSourcePositionPreserved() {
+ var source = "> [!NOTE] Custom title\n> Body text";
+ var document = PARSER_CUSTOM_TITLES.parse(source);
+ var alert = (Alert) document.getFirstChild();
+ var title = (AlertTitle) alert.getFirstChild();
+
+ // "Custom title" is at column 10, length 12 in line 0
+ assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 12)));
}
- // AST
+ @Test
+ public void titleSourcePositionPreservedBetweenBlocks() {
+ var source = "- List\n\n> [!NOTE] Custom title\n> Body text\n\nPlain paragraph";
+ var document = PARSER_CUSTOM_TITLES.parse(source);
+ var alert = (Alert) document.getFirstChild().getNext();
+ var title = (AlertTitle) alert.getFirstChild();
+
+ // "Custom title" is at column 10, length 12 in line 2
+ assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(2, 10, 18, 12)));
+ }
@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");
+ public void titleSourcePositionWithLeadingAndTrailingSpaces() {
+ var source = "> [!NOTE] Custom title \n> Body text";
+ var document = PARSER_CUSTOM_TITLES.parse(source);
+ var alert = (Alert) document.getFirstChild();
+ var title = (AlertTitle) alert.getFirstChild();
+
+ // Both leading and trailing spaces are trimmed
+ assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 13, 13, 12)));
}
@Test
- public void customTypeParsedAsAlertNode() {
- Extension extension = AlertsExtension.builder()
- .addCustomType("INFO", "Information")
- .build();
+ public void titleWithInlineFormattingSourcePosition() {
+ var source = "> [!NOTE] Custom _title_\n> Body text";
+ var document = PARSER_CUSTOM_TITLES.parse(source);
+ var alert = (Alert) document.getFirstChild();
+ var title = (AlertTitle) alert.getFirstChild();
- Parser parser = Parser.builder().extensions(Set.of(extension)).build();
+ // "Custom _title_" is at column 10, length 14
+ assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 14)));
- Node document = parser.parse("> [!INFO]\n> Custom alert");
- Alert alert = (Alert) document.getFirstChild();
+ // First child: "Custom " text node
+ var firstText = title.getFirstChild();
+ assertThat(firstText).isInstanceOf(Text.class);
+ assertThat(((Text) firstText).getLiteral()).isEqualTo("Custom ");
+ assertThat(firstText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 7)));
- assertThat(alert.getType()).isEqualTo("INFO");
+ // Second child: emphasis node containing "title"
+ var emphasis = firstText.getNext();
+ assertThat(emphasis).isInstanceOf(Emphasis.class);
+ assertThat(emphasis.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 17, 17, 7)));
+
+ // Text inside emphasis: "title"
+ var titleText = emphasis.getFirstChild();
+ assertThat(titleText).isInstanceOf(Text.class);
+ assertThat(((Text) titleText).getLiteral()).isEqualTo("title");
+ assertThat(titleText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 18, 18, 5)));
+ }
+
+ @Test
+ public void titleWithNestedInlineFormattingSourcePosition() {
+ var source = "> [!NOTE] Text with **bold _and italic_**\n> Body text";
+ var document = PARSER_CUSTOM_TITLES.parse(source);
+ var alert = (Alert) document.getFirstChild();
+ var title = (AlertTitle) alert.getFirstChild();
+
+ // "Custom _title_" is at column 10, length 14
+ assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 31)));
+
+ // First child: "Text with " text node
+ var firstText = title.getFirstChild();
+ assertThat(firstText).isInstanceOf(Text.class);
+ assertThat(((Text) firstText).getLiteral()).isEqualTo("Text with ");
+ assertThat(firstText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 10)));
+
+ // Second child: strong emphasis node
+ var strong = firstText.getNext();
+ assertThat(strong).isInstanceOf(StrongEmphasis.class);
+ assertThat(strong.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 20, 20, 21)));
+
+ // Inside strong: "bold " text
+ var boldText = strong.getFirstChild();
+ assertThat(boldText).isInstanceOf(Text.class);
+ assertThat(((Text) boldText).getLiteral()).isEqualTo("bold ");
+ assertThat(boldText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 22, 22, 5)));
+
+ // Inside strong: emphasis node with "and italic"
+ var emphasis = boldText.getNext();
+ assertThat(emphasis).isInstanceOf(Emphasis.class);
+ assertThat(emphasis.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 27, 27, 12)));
+
+ // Text inside emphasis: "and italic"
+ var italicText = emphasis.getFirstChild();
+ assertThat(italicText).isInstanceOf(Text.class);
+ assertThat(((Text) italicText).getLiteral()).isEqualTo("and italic");
+ assertThat(italicText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 28, 28, 10)));
}
}
From 348d18b8ab667157582ff0ca2f60880219b3757e Mon Sep 17 00:00:00 2001
From: Ryan DeStefano <67760716+rdestefa@users.noreply.github.com>
Date: Mon, 1 Jun 2026 03:48:46 -0700
Subject: [PATCH 6/6] Remove HTML Comment Handling
---
.../internal/AlertHtmlNodeRenderer.java | 49 -------------------
.../commonmark/ext/gfm/alerts/AlertsTest.java | 18 -------
2 files changed, 67 deletions(-)
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
index 062f637e..af9fff70 100644
--- 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
@@ -2,9 +2,7 @@
import org.commonmark.ext.gfm.alerts.Alert;
import org.commonmark.ext.gfm.alerts.AlertTitle;
-import org.commonmark.node.HtmlInline;
import org.commonmark.node.Node;
-import org.commonmark.node.Text;
import org.commonmark.renderer.html.HtmlNodeRendererContext;
import org.commonmark.renderer.html.HtmlWriter;
@@ -40,21 +38,6 @@ protected void renderAlert(Alert alert) {
htmlWriter.tag("p", context.extendAttributes(alert, "p", Map.of("class", "markdown-alert-title")));
var first = alert.getFirstChild();
if (first instanceof AlertTitle) {
- /*
- * If the alert title only contains HTML comments like this:
- *
- * > [!TIP]
- * > Body text
- *
- * Then the reader will see a blank title. In this case, it's better
- * to render the default title (but keep the comments for accuracy).
- *
- * These comments will be visible when rendered to other formats like
- * Markdown, so this is only relevant for the HTML renderer.
- */
- if (isCommentOnlyTitle(first)) {
- htmlWriter.text(getAlertTitle(type));
- }
renderChildren(first);
} else {
htmlWriter.text(getAlertTitle(type));
@@ -90,38 +73,6 @@ private String getAlertTitle(String type) {
}
}
- private boolean isCommentOnlyTitle(Node title) {
- var node = title.getFirstChild();
- if (node == null) {
- return false;
- }
- while (node != null) {
- if (node instanceof HtmlInline) {
- if (!isHtmlComment((HtmlInline) node)) {
- return false;
- }
- } else if (node instanceof Text) {
- if (!((Text) node).getLiteral().trim().isEmpty()) {
- return false;
- }
- } else {
- return false;
- }
- node = node.getNext();
- }
- return true;
- }
-
- private boolean isHtmlComment(HtmlInline htmlInline) {
- var literal = htmlInline.getLiteral();
- if (literal == null || !literal.startsWith("")
- || literal.equals("")
- || literal.endsWith("-->");
- }
-
private void renderChildren(Node parent) {
var node = parent.getFirstChild();
while (node != null) {
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
index fe01145a..e39ce8f7 100644
--- 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
@@ -184,15 +184,6 @@ public void customTitleWithComment() {
"\n");
}
- @Test
- public void customTitleWithOnlyCommentFallsBackToDefaultTitle() {
- assertRenderingCustomTitles("> [!TIP] \n> Body text",
- "\n" +
- "Tip
\n" +
- "Body text
\n" +
- "\n");
- }
-
@Test
public void customTitleWithInlineFormatting() {
assertRenderingCustomTitles("> [!NOTE] Custom _title with **formatting**_\n> Note with a custom title",
@@ -264,15 +255,6 @@ public void onlyTrailingWhitespaceIsNotCustomTitle() {
"\n");
}
- @Test
- public void onlyHtmlCommentsInTitleUseDefaultTitle() {
- assertRenderingCustomTitles("> [!NOTE] \n> Body text",
- "\n" +
- "Note
\n" +
- "Body text
\n" +
- "\n");
- }
-
// Lazy continuation
@Test