Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ with the exception that 0.x versions can break between minor versions.
other blocks (including other alerts). See
[this section of the alerts README](./commonmark-ext-gfm-alerts/README.md#nesting-alerts)
for more information.
- New configuration for `AlertsExtension` to allow the set of alert types
(including standard GFM types) to be completely overwritten.
```java
var extension = AlertsExtension.builder()
.setAllowedTypes(Map.ofEntries(
Map.entry("IMPORTANT", "Important"),
Map.entry("WARNING", "Warning")
Map.entry("BUG", "Known Bug")
))
.build();
```

## [0.28.0] - 2026-03-31
### Added
Expand Down
12 changes: 12 additions & 0 deletions commonmark-ext-gfm-alerts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ var extension = AlertsExtension.builder()

Custom types must be UPPERCASE. Standard type titles can also be overridden for localization.

The allowed types (including the five standard GFM types) can also be completely overwritten:

```java
var extension = AlertsExtension.builder()
.setAllowedTypes(Map.ofEntries(
Map.entry("IMPORTANT", "Important"),
Map.entry("WARNING", "Warning")
Map.entry("BUG", "Known Bug")
))
.build();
```

### Custom Alert Titles

Allow authors to provide custom titles per alert by adding text after the alert
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import java.util.Locale;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
Expand Down Expand Up @@ -49,14 +50,27 @@
public class AlertsExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension,
MarkdownRenderer.MarkdownRendererExtension {

static final Set<String> STANDARD_TYPES = Set.of("NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION");
/**
* The standard GitHub Flavored Markdown (GFM) types that the extension
* enables by default. These can be overwritten with {@link Builder#setAllowedTypes(Map)}.
*/
public static final Map<String, String> STANDARD_TYPES = Map.ofEntries(
Map.entry("NOTE", "Note"),
Map.entry("TIP", "Tip"),
Map.entry("IMPORTANT", "Important"),
Map.entry("WARNING", "Warning"),
Map.entry("CAUTION", "Caution")
);

private final Map<String, String> customTypes;
/**
* A map of alert marker ({@code [!TYPE]}) to the default title for that marker.
*/
private final Map<String, String> allowedTypes;
private final boolean customTitlesAllowed;
private final boolean nestedAlertsAllowed;

private AlertsExtension(Builder builder) {
this.customTypes = new HashMap<>(builder.customTypes);
this.allowedTypes = new HashMap<>(builder.allowedTypes);
this.customTitlesAllowed = builder.customTitlesAllowed;
this.nestedAlertsAllowed = builder.nestedAlertsAllowed;
}
Expand All @@ -71,15 +85,14 @@ public static Builder builder() {

@Override
public void extend(Parser.Builder parserBuilder) {
var allowedTypes = new HashSet<>(STANDARD_TYPES);
allowedTypes.addAll(customTypes.keySet());
var allowedTypesSet = new HashSet<>(allowedTypes.keySet());
parserBuilder.customBlockParserFactory(
new AlertBlockParser.Factory(allowedTypes, customTitlesAllowed, nestedAlertsAllowed));
new AlertBlockParser.Factory(allowedTypesSet, customTitlesAllowed, nestedAlertsAllowed));
}

@Override
public void extend(HtmlRenderer.Builder rendererBuilder) {
rendererBuilder.nodeRendererFactory(context -> new AlertHtmlNodeRenderer(context, customTypes));
rendererBuilder.nodeRendererFactory(context -> new AlertHtmlNodeRenderer(context, allowedTypes));
}

@Override
Expand All @@ -101,37 +114,57 @@ public Set<Character> getSpecialCharacters() {
* Builder for configuring the alerts extension.
*/
public static class Builder {
private final Map<String, String> customTypes = new HashMap<>();
private Map<String, String> allowedTypes = new HashMap<>(STANDARD_TYPES);
private boolean customTitlesAllowed = false;
private boolean nestedAlertsAllowed = false;

/**
* Adds a custom alert type with a display title.
* Sets which alert types will be recognized and parsed into {@link Alert} blocks,
* completely overwriting any previous configuration.
* <p>
* This can also be used to override the display title of standard GFM types
* By default, {@link AlertsExtension#STANDARD_TYPES} are used.
*
* @param allowedTypes A map of alert type to the default title for that type.
* Must not be null/empty or contain any null/empty keys or
* values. Additionally, all alert types must be uppercase.
* @return {@code this}
* @see Builder#addCustomType(String, String)
*/
public Builder setAllowedTypes(Map<String, String> allowedTypes) {
Objects.requireNonNull(allowedTypes, "allowedTypes must not be null");
if (allowedTypes.isEmpty()) {
throw new IllegalArgumentException("allowedTypes must not be empty");
}

for (Map.Entry<String, String> entry : allowedTypes.entrySet()) {
validateTypeAndTitle(entry.getKey(), entry.getValue());
}

this.allowedTypes = new HashMap<>(allowedTypes);
return this;
}

/**
* Adds a custom alert type with a default title.
* <p>
* This can also be used to override the default 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
* @param title the default title for this alert type
* @return {@code this}
* @see Builder#setAllowedTypes(Map)
*/
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(Locale.ROOT))) {
throw new IllegalArgumentException("Type must be uppercase: " + type);
}
customTypes.put(type, title);
validateTypeAndTitle(type, title);
allowedTypes.put(type, title);
return this;
}

/**
* Allows or disallows custom titles on alerts. Inline formatting is supported
* within these titles.
*
* @param allow Whether to allow or disallow custom titles on alerts.
* @return {@code this}
* @see AlertTitle
Expand All @@ -149,6 +182,7 @@ public Builder allowCustomTitles(boolean allow) {
* <p>
* 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}
*/
Expand All @@ -163,5 +197,29 @@ public Builder allowNestedAlerts(boolean allow) {
public Extension build() {
return new AlertsExtension(this);
}

/**
* Checks whether an alert type and default title are valid.
*
* @param type The type to validate:
* <p>
* - Must not be null or empty
* - Must be uppercase
* @param title The default title to validate. Must not be null or empty.
*/
private void validateTypeAndTitle(String type, String title) {
Objects.requireNonNull(type, "Type must not be null");
if (type.isEmpty()) {
throw new IllegalArgumentException("Type must not be empty");
}
if (!type.equals(type.toUpperCase(Locale.ROOT))) {
throw new IllegalArgumentException("Type must be uppercase: " + type);
}

Objects.requireNonNull(title, "Default title must not be null: " + type);
if (title.isEmpty()) {
throw new IllegalArgumentException("Default title must not be empty: " + type);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ public class AlertHtmlNodeRenderer extends AlertNodeRenderer {

private final HtmlWriter htmlWriter;
private final HtmlNodeRendererContext context;
private final Map<String, String> customTypeTitles;
private final Map<String, String> allowedTypes;

public AlertHtmlNodeRenderer(HtmlNodeRendererContext context, Map<String, String> customTypeTitles) {
public AlertHtmlNodeRenderer(HtmlNodeRendererContext context, Map<String, String> allowedTypes) {
this.htmlWriter = context.getWriter();
this.context = context;
this.customTypeTitles = customTypeTitles;
this.allowedTypes = allowedTypes;
}

@Override
Expand Down Expand Up @@ -53,24 +53,11 @@ protected void renderAlert(Alert alert) {
}

private String getAlertTitle(String type) {
var customTypeTitle = customTypeTitles.get(type);
if (customTypeTitle != null) {
return customTypeTitle;
}
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);
var typeTitle = allowedTypes.get(type);
if (typeTitle == null) {
throw new IllegalStateException("Unknown alert type: " + type);
}
return typeTitle;
}

private void renderChildren(Node parent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,41 +40,41 @@ public void allStandardTypesRoundTrip() {
@Test
public void lowercaseTypeRendersAsUppercase() {
// Lowercase input gets normalized to uppercase type
String rendered = RENDERER.render(PARSER.parse("> [!note]\n> Content\n"));
var rendered = RENDERER.render(PARSER.parse("> [!note]\n> Content\n"));
assertThat(rendered).isEqualTo("> [!NOTE]\n> Content\n");
}

@Test
public void leadingAndTrailingLinesAreRemoved() {
String rendered = RENDERER.render(PARSER.parse(">\n> \n>[!NOTE]\n> Content\n>\n> \n"));
var rendered = RENDERER.render(PARSER.parse(">\n> \n>[!NOTE]\n> Content\n>\n> \n"));
assertThat(rendered).isEqualTo("> [!NOTE]\n> Content\n");
}

@Test
public void alertWithMultipleParagraphs() {
String input = "> [!NOTE]\n> First paragraph\n>\n> Second paragraph\n";
var 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));
var expected = "> [!NOTE]\n> First paragraph\n> \n> Second paragraph\n";
var rendered = RENDERER.render(PARSER.parse(input));
assertThat(rendered).isEqualTo(expected);
}

@Test
public void customTypeRoundTrip() {
Extension extension = AlertsExtension.builder()
var 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";
var parser = Parser.builder().extensions(Set.of(extension)).build();
var renderer = MarkdownRenderer.builder().extensions(Set.of(extension)).build();
var input = "> [!INFO]\n> Custom type\n";

assertRoundTrip(input, parser, renderer);
}

@Test
public void alertWithList() {
String input = "> [!NOTE]\n> Items:\n> \n> - First\n> - Second\n";
var input = "> [!NOTE]\n> Items:\n> \n> - First\n> - Second\n";
assertRoundTrip(input);
}

Expand All @@ -92,17 +92,17 @@ public void customTitleWithFormattingRoundTrip() {

@Test
public void customTitleWithMultipleBlocks() {
String input = "> [!NOTE]Title\n> First paragraph\n>\n> Second paragraph\n>\n> - > Nested blocks\n";
var input = "> [!NOTE]Title\n> First paragraph\n>\n> Second paragraph\n>\n> - > Nested blocks\n";
// MarkdownWriter always writes the prefix including trailing space
String expected = "> [!NOTE] Title\n> First paragraph\n> \n> Second paragraph\n> \n> - > Nested blocks\n";
String rendered = RENDERER_CUSTOM_TITLES.render(PARSER_CUSTOM_TITLES.parse(input));
var expected = "> [!NOTE] Title\n> First paragraph\n> \n> Second paragraph\n> \n> - > Nested blocks\n";
var rendered = RENDERER_CUSTOM_TITLES.render(PARSER_CUSTOM_TITLES.parse(input));
assertThat(rendered).isEqualTo(expected);
}

// Helpers

private void assertRoundTrip(String input, Parser parser, MarkdownRenderer renderer) {
String rendered = renderer.render(parser.parse(input));
var rendered = renderer.render(parser.parse(input));
assertThat(rendered).isEqualTo(input);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Map;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -137,6 +138,66 @@ public void customTypeTitleMustNotBeEmpty() {
AlertsExtension.builder().addCustomType("INFO", "").build());
}

// Overwriting types

@Test
public void overwriteStandardTypes() {
var allowedTypes = Map.ofEntries(Map.entry("IMPORTANT", "Important"));
var extension = AlertsExtension.builder()
.setAllowedTypes(allowedTypes)
.addCustomType("BUG", "Known Bug")
.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> Regular block quote"))).isEqualTo(
"<blockquote>\n" +
"<p>[!NOTE]\n" +
"Regular block quote</p>\n" +
"</blockquote>\n");

assertThat(renderer.render(parser.parse("> [!TIP]\n> Regular block quote"))).isEqualTo(
"<blockquote>\n" +
"<p>[!TIP]\n" +
"Regular block quote</p>\n" +
"</blockquote>\n");

assertThat(renderer.render(parser.parse("> [!IMPORTANT]\n> Alert"))).isEqualTo(
"<div class=\"markdown-alert markdown-alert-important\" data-alert-type=\"important\">\n" +
"<p class=\"markdown-alert-title\">Important</p>\n" +
"<p>Alert</p>\n" +
"</div>\n");

assertThat(renderer.render(parser.parse("> [!BUG]\n> Alert"))).isEqualTo(
"<div class=\"markdown-alert markdown-alert-bug\" data-alert-type=\"bug\">\n" +
"<p class=\"markdown-alert-title\">Known Bug</p>\n" +
"<p>Alert</p>\n" +
"</div>\n");
}

// Overwriting types validation

@Test
public void overwriteTypesMustBeUppercase() {
var allowedTypes = Map.ofEntries(Map.entry("info", "Info"));
assertThrows(IllegalArgumentException.class, () ->
AlertsExtension.builder().setAllowedTypes(allowedTypes).build());
}

@Test
public void overwriteTypesMustNotBeEmpty() {
var allowedTypes = Map.ofEntries(Map.entry("", "Info"));
assertThrows(IllegalArgumentException.class, () ->
AlertsExtension.builder().setAllowedTypes(allowedTypes).build());
}

@Test
public void overwriteTypesTitleMustNotBeEmpty() {
var allowedTypes = Map.ofEntries(Map.entry("INFO", ""));
assertThrows(IllegalArgumentException.class, () ->
AlertsExtension.builder().setAllowedTypes(allowedTypes).build());
}

// Custom titles

@Test
Expand Down
Loading