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(boolean)
+ */
+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..cb29fd5a 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,26 @@
* ({@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 shortcut reference links.
+ * - Alerts with no content are allowed. Example:
+ *
+ * {@code
+ *
+ * > [!NOTE]
+ *
+ *
+ * > [!NOTE] Custom title
+ * }
+ * - 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 +51,13 @@ public class AlertsExtension implements Parser.ParserExtension, HtmlRenderer.Htm
static final Set+ * 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 allowNestedAlerts(boolean allow) {
+ nestedAlertsAllowed = allow;
+ 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..e7a9f402
--- /dev/null
+++ b/commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertBlockParser.java
@@ -0,0 +1,226 @@
+package org.commonmark.ext.gfm.alerts.internal;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+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.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 SourceLine titleLine;
+
+ private AlertBlockParser(String type, SourceLine titleLine) {
+ this.block = new Alert(type);
+ this.titleLine = titleLine;
+ }
+
+ @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();
+ var nextNonSpace = state.getNextNonSpaceIndex();
+ if (state.getIndent() >= 4 // Parsing.CODE_BLOCK_INDENT
+ || nextNonSpace >= line.length()
+ || line.charAt(nextNonSpace) != '>') {
+ return BlockContinue.none();
+ }
+
+ var newColumn = state.getColumn() + state.getIndent() + 1;
+ if (Characters.isSpaceOrTab(line, nextNonSpace + 1)) {
+ newColumn++;
+ }
+
+ return BlockContinue.atColumn(newColumn);
+ }
+
+ @Override
+ public void parseInlines(InlineParser inlineParser) {
+ if (titleLine == null || titleLine.getContent().length() == 0) {
+ 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(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);
+ }
+
+ 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..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
@@ -1,31 +1,56 @@
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;
import static org.junit.jupiter.api.Assertions.assertThrows;
-public class AlertsTest {
+public class AlertsTest extends RenderingTestCase {
private static final Set Custom title Note with a custom title Custom title Note with a custom title Custom title Note with a custom title Custom title\\ Note with a custom title Custom title Note with a custom title Custom title with formatting Note with a custom title See docs or Note with a custom title Custom title Note with a custom title Custom _title with **opening `delimiters Note` with** closing delimiters_ ## Custom title looks like an ATX heading But it's not Custom title Custom title Note Body text Note Body text Custom title Body text : https://example.com Body text [!NOTE] Note Body text Note Body text Tip Body [!TIP]\n" +
+ "Nested body Tip Body [!TIP]\n" +
+ "Nested body Tip Tip body Important Important body Note Nested body Ordered list body Caution Deeply nested body [!NOTE] [!TIP] [!NOTE] Some textrun()\n" +
+ "
\n");
+ }
+
+ @Test
+ public void noNestedAlertsByDefaultLeadingEmptyLines() {
+ assertRendering(">\n> \n> [!TIP]\n> Body\n\n- > [!TIP]\n > Nested body",
+ "\n" +
+ "
\n" +
+ "\n" +
+ "
\n");
+ }
+
+ @Test
+ public void nestedAlerts() {
+ 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]",
+ "> Tip body",
+ "> > [!IMPORTANT]",
+ "> > Important body",
+ "",
+ "- > [!NOTE]",
+ " > Nested body",
+ " >",
+ " > 1. Ordered list body",
+ " >",
+ " > > [!CAUTION]",
+ " > >",
+ " > > Deeply nested body");
+ var expected = String.join("\n",
+ "\n" +
+ "
\n" +
+ "",
+ "
\n");
+
+ assertThat(renderer.render(parser.parse(source))).isEqualTo(expected);
+ }
+
// AST
@Test
public void alertParsedAsAlertNode() {
- Node document = PARSER.parse("> [!NOTE]\n> This is a note");
- Node firstChild = document.getFirstChild();
+ var document = PARSER.parse("> [!NOTE]\n> This is a note");
+ var firstChild = document.getFirstChild();
assertThat(firstChild).isInstanceOf(Alert.class);
- Alert alert = (Alert) firstChild;
+ var alert = (Alert) firstChild;
assertThat(alert.getType()).isEqualTo("NOTE");
}
@Test
public void customTypeParsedAsAlertNode() {
- Extension extension = AlertsExtension.builder()
+ var extension = AlertsExtension.builder()
.addCustomType("INFO", "Information")
.build();
- Parser parser = Parser.builder().extensions(Set.of(extension)).build();
+ var parser = Parser.builder().extensions(Set.of(extension)).build();
- Node document = parser.parse("> [!INFO]\n> Custom alert");
- Alert alert = (Alert) document.getFirstChild();
+ var document = parser.parse("> [!INFO]\n> Custom alert");
+ var alert = (Alert) document.getFirstChild();
assertThat(alert.getType()).isEqualTo("INFO");
}
-}
\ No newline at end of file
+ // Source positions
+
+ @Test
+ 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)));
+ }
+
+ @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 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 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();
+
+ // "Custom _title_" is at column 10, length 14
+ assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 14)));
+
+ // 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)));
+
+ // 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)));
+ }
+
+}
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]
-.
-",
+ "
",
+ "
-
-````````````````````````````````
-
-Whitespace-only content after marker:
-
-```````````````````````````````` example alert
-> [!TIP]
->
->
-.
-
-
-````````````````````````````````
-
Extra space inside marker:
```````````````````````````````` example alert
@@ -342,19 +320,6 @@ Leading spaces before blockquote marker:
-
-