From 7f7d9d8a522563c5083ca3a233f507438f660a61 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/2] Add Support for Custom Alert Titles --- CHANGELOG.md | 16 +- 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 +++++++++++++++++- .../testutil/RenderingTestCase.java | 2 - 14 files changed, 765 insertions(+), 147 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 9c5c67268..29a61b48f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,20 @@ with the exception that 0.x versions can break between minor versions. ## [Unreleased] ### Added - 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. ``` @@ -102,9 +110,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 @@ -269,7 +277,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 845226729..6ea222e6a 100644 --- a/README.md +++ b/README.md @@ -337,21 +337,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 @@ -368,7 +369,7 @@ is based on the text of the heading. `# Heading` will be rendered as: -``` +```html
```
@@ -434,12 +435,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 3990034d2..116299b05 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;
@@ -26,6 +26,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 {
@@ -33,9 +44,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 000000000..d062a1a39
--- /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 c46c532fe..f812d3f8b 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" + + "