Skip to content

Commit 1b8aef6

Browse files
ia3andyclaude
andcommitted
Add GFM alerts extension
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent e62bca1 commit 1b8aef6

File tree

18 files changed

+1292
-0
lines changed

18 files changed

+1292
-0
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# commonmark-ext-gfm-alerts
2+
3+
Extension for [commonmark-java](https://github.com/commonmark/commonmark-java) that adds support for [GitHub Flavored Markdown alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts).
4+
5+
Enables highlighting important information using blockquote syntax with five standard alert types: NOTE, TIP, IMPORTANT, WARNING, and CAUTION.
6+
7+
## Usage
8+
9+
#### Markdown Syntax
10+
11+
```markdown
12+
> [!NOTE]
13+
> Useful information
14+
15+
> [!WARNING]
16+
> Critical information
17+
```
18+
19+
#### Standard GFM Types
20+
21+
```java
22+
Extension extension = AlertsExtension.create();
23+
Parser parser = Parser.builder().extensions(List.of(extension)).build();
24+
HtmlRenderer renderer = HtmlRenderer.builder().extensions(List.of(extension)).build();
25+
```
26+
27+
#### Custom Alert Types
28+
29+
Add custom types beyond the five standard GFM types:
30+
31+
```java
32+
Extension extension = AlertsExtension.builder()
33+
.addCustomType("INFO", "Information")
34+
.build();
35+
```
36+
37+
Custom types must be UPPERCASE and cannot override standard types.
38+
39+
#### Styling
40+
41+
Alerts render as `<div>` elements with CSS classes:
42+
43+
```html
44+
<div class="markdown-alert markdown-alert-note" data-alert-type="note">
45+
<p class="markdown-alert-title">Note</p>
46+
<p>Content</p>
47+
</div>
48+
```
49+
50+
Basic CSS example:
51+
52+
```css
53+
.markdown-alert {
54+
padding: 0.5rem 1rem;
55+
margin-bottom: 1rem;
56+
border-left: 4px solid;
57+
}
58+
59+
.markdown-alert-note { border-color: #0969da; background-color: #ddf4ff; }
60+
.markdown-alert-tip { border-color: #1a7f37; background-color: #dcffe4; }
61+
.markdown-alert-important { border-color: #8250df; background-color: #f6f0ff; }
62+
.markdown-alert-warning { border-color: #9a6700; background-color: #fff8c5; }
63+
.markdown-alert-caution { border-color: #cf222e; background-color: #ffebe9; }
64+
```
65+
66+
Icons can be added using CSS `::before` pseudo-elements with GitHub's [Octicons](https://primer.style/octicons/) (info, light-bulb, report, alert, stop icons).
67+
68+
## License
69+
70+
See the main commonmark-java project for license information.

commonmark-ext-gfm-alerts/pom.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
<parent>
5+
<groupId>org.commonmark</groupId>
6+
<artifactId>commonmark-parent</artifactId>
7+
<version>0.27.2-SNAPSHOT</version>
8+
</parent>
9+
10+
<artifactId>commonmark-ext-gfm-alerts</artifactId>
11+
<name>commonmark-java extension for alerts</name>
12+
<description>commonmark-java extension for GFM alerts (admonition blocks) using [!TYPE] syntax (GitHub Flavored Markdown)</description>
13+
14+
<dependencies>
15+
<dependency>
16+
<groupId>org.commonmark</groupId>
17+
<artifactId>commonmark</artifactId>
18+
</dependency>
19+
20+
<dependency>
21+
<groupId>org.commonmark</groupId>
22+
<artifactId>commonmark-test-util</artifactId>
23+
<scope>test</scope>
24+
</dependency>
25+
</dependencies>
26+
27+
</project>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module org.commonmark.ext.gfm.alerts {
2+
exports org.commonmark.ext.gfm.alerts;
3+
4+
requires transitive org.commonmark;
5+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.commonmark.ext.gfm.alerts;
2+
3+
import org.commonmark.node.CustomBlock;
4+
5+
import java.util.Set;
6+
7+
/**
8+
* Alert block for highlighting important information using {@code [!TYPE]} syntax.
9+
*/
10+
public class Alert extends CustomBlock {
11+
12+
public static final Set<String> STANDARD_TYPES = Set.of("NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION");
13+
14+
private String type;
15+
16+
public String getType() {
17+
return type;
18+
}
19+
20+
public void setType(String type) {
21+
this.type = type;
22+
}
23+
24+
public boolean isStandardType() {
25+
return STANDARD_TYPES.contains(type);
26+
}
27+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package org.commonmark.ext.gfm.alerts;
2+
3+
import org.commonmark.Extension;
4+
import org.commonmark.ext.gfm.alerts.internal.AlertBlockParser;
5+
import org.commonmark.ext.gfm.alerts.internal.AlertHtmlNodeRenderer;
6+
import org.commonmark.ext.gfm.alerts.internal.AlertMarkdownNodeRenderer;
7+
import org.commonmark.ext.gfm.alerts.internal.AlertTextContentNodeRenderer;
8+
import org.commonmark.parser.Parser;
9+
import org.commonmark.renderer.NodeRenderer;
10+
import org.commonmark.renderer.html.HtmlRenderer;
11+
import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
12+
import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory;
13+
import org.commonmark.renderer.markdown.MarkdownRenderer;
14+
import org.commonmark.renderer.text.TextContentRenderer;
15+
16+
import java.util.Collections;
17+
import java.util.LinkedHashMap;
18+
import java.util.Map;
19+
import java.util.Set;
20+
21+
/**
22+
* Extension for GFM alerts using {@code [!TYPE]} syntax (GitHub Flavored Markdown).
23+
* <p>
24+
* Create with {@link #create()} or {@link #builder()} and configure on builders
25+
* ({@link org.commonmark.parser.Parser.Builder#extensions(Iterable)},
26+
* {@link HtmlRenderer.Builder#extensions(Iterable)}).
27+
* Parsed alerts become {@link Alert} blocks.
28+
*/
29+
public class AlertsExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension,
30+
TextContentRenderer.TextContentRendererExtension, MarkdownRenderer.MarkdownRendererExtension {
31+
32+
private final Map<String, String> customTypes;
33+
34+
private AlertsExtension(Builder builder) {
35+
this.customTypes = new LinkedHashMap<>(builder.customTypes);
36+
}
37+
38+
public static Extension create() {
39+
return new AlertsExtension(builder());
40+
}
41+
42+
public static Builder builder() {
43+
return new Builder();
44+
}
45+
46+
public Map<String, String> getCustomTypes() {
47+
return Collections.unmodifiableMap(customTypes);
48+
}
49+
50+
@Override
51+
public void extend(Parser.Builder parserBuilder) {
52+
parserBuilder.customBlockParserFactory(new AlertBlockParser.Factory(this));
53+
}
54+
55+
@Override
56+
public void extend(HtmlRenderer.Builder rendererBuilder) {
57+
rendererBuilder.nodeRendererFactory(context -> new AlertHtmlNodeRenderer(context, customTypes));
58+
}
59+
60+
@Override
61+
public void extend(TextContentRenderer.Builder rendererBuilder) {
62+
rendererBuilder.nodeRendererFactory(AlertTextContentNodeRenderer::new);
63+
}
64+
65+
@Override
66+
public void extend(MarkdownRenderer.Builder rendererBuilder) {
67+
rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() {
68+
@Override
69+
public NodeRenderer create(MarkdownNodeRendererContext context) {
70+
return new AlertMarkdownNodeRenderer(context);
71+
}
72+
73+
@Override
74+
public Set<Character> getSpecialCharacters() {
75+
return Set.of();
76+
}
77+
});
78+
}
79+
80+
/**
81+
* Builder for configuring the alerts extension.
82+
*/
83+
public static class Builder {
84+
private final Map<String, String> customTypes = new LinkedHashMap<>();
85+
86+
/**
87+
* Adds a custom alert type with a display title.
88+
* <p>
89+
* Custom types must:
90+
* <ul>
91+
* <li>Be UPPERCASE (e.g., "INFO", "SUCCESS")</li>
92+
* <li>Not conflict with standard GFM types (NOTE, TIP, IMPORTANT, WARNING, CAUTION)</li>
93+
* </ul>
94+
*
95+
* @param type the alert type (must be uppercase)
96+
* @param title the display title for this alert type
97+
* @return {@code this}
98+
*/
99+
public Builder addCustomType(String type, String title) {
100+
if (type == null || type.isEmpty()) {
101+
throw new IllegalArgumentException("Type must not be null or empty");
102+
}
103+
if (title == null || title.isEmpty()) {
104+
throw new IllegalArgumentException("Title must not be null or empty");
105+
}
106+
if (!type.equals(type.toUpperCase())) {
107+
throw new IllegalArgumentException("Type must be uppercase: " + type);
108+
}
109+
if (Alert.STANDARD_TYPES.contains(type)) {
110+
throw new IllegalArgumentException("Cannot override standard GFM type: " + type);
111+
}
112+
customTypes.put(type, title);
113+
return this;
114+
}
115+
116+
/**
117+
* @return a configured {@link Extension}
118+
*/
119+
public Extension build() {
120+
return new AlertsExtension(this);
121+
}
122+
}
123+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package org.commonmark.ext.gfm.alerts.internal;
2+
3+
import org.commonmark.ext.gfm.alerts.Alert;
4+
import org.commonmark.ext.gfm.alerts.AlertsExtension;
5+
import org.commonmark.node.Block;
6+
import org.commonmark.parser.block.*;
7+
import org.commonmark.text.Characters;
8+
9+
import java.util.HashSet;
10+
import java.util.Set;
11+
import java.util.regex.Matcher;
12+
import java.util.regex.Pattern;
13+
14+
public class AlertBlockParser extends AbstractBlockParser {
15+
16+
private static final Pattern ALERT_PATTERN = Pattern.compile("^\\[!([A-Z]+)]$");
17+
18+
// Duplicates org.commonmark.internal.util.Parsing.CODE_BLOCK_INDENT which is not accessible
19+
private static final int CODE_BLOCK_INDENT = 4;
20+
21+
private final Alert block = new Alert();
22+
23+
public AlertBlockParser(String type) {
24+
if (type == null) {
25+
throw new IllegalArgumentException("Alert type must not be null");
26+
}
27+
block.setType(type);
28+
}
29+
30+
@Override
31+
public boolean isContainer() {
32+
return true;
33+
}
34+
35+
@Override
36+
public boolean canContain(Block block) {
37+
return true;
38+
}
39+
40+
@Override
41+
public Block getBlock() {
42+
return block;
43+
}
44+
45+
@Override
46+
public BlockContinue tryContinue(ParserState state) {
47+
int nextNonSpace = state.getNextNonSpaceIndex();
48+
if (isMarker(state, nextNonSpace)) {
49+
int newColumn = state.getColumn() + state.getIndent() + 1;
50+
// Optional following space or tab after '>'
51+
CharSequence line = state.getLine().getContent();
52+
if (nextNonSpace + 1 < line.length() && Characters.isSpaceOrTab(line, nextNonSpace + 1)) {
53+
newColumn++;
54+
}
55+
return BlockContinue.atColumn(newColumn);
56+
} else {
57+
return BlockContinue.none();
58+
}
59+
}
60+
61+
/**
62+
* Checks if the character at the given index is a blockquote marker ('>').
63+
*
64+
* @param state the parser state
65+
* @param index the index to check
66+
* @return true if the character is '>' and indentation is less than CODE_BLOCK_INDENT
67+
*/
68+
private static boolean isMarker(ParserState state, int index) {
69+
CharSequence line = state.getLine().getContent();
70+
return state.getIndent() < CODE_BLOCK_INDENT && index < line.length() && line.charAt(index) == '>';
71+
}
72+
73+
/**
74+
* Factory for creating alert block parsers.
75+
*/
76+
public static class Factory extends AbstractBlockParserFactory {
77+
private final Set<String> allowedTypes;
78+
79+
public Factory(AlertsExtension extension) {
80+
// Combine standard GFM types with custom types
81+
this.allowedTypes = new HashSet<>(Alert.STANDARD_TYPES);
82+
this.allowedTypes.addAll(extension.getCustomTypes().keySet());
83+
}
84+
85+
@Override
86+
public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) {
87+
int nextNonSpace = state.getNextNonSpaceIndex();
88+
if (!isMarker(state, nextNonSpace)) {
89+
return BlockStart.none();
90+
}
91+
92+
// Check if this is an alert by looking for [!TYPE] pattern
93+
CharSequence line = state.getLine().getContent();
94+
int contentStart = nextNonSpace + 1;
95+
// Skip optional space/tab after >
96+
if (contentStart < line.length() && Characters.isSpaceOrTab(line, contentStart)) {
97+
contentStart++;
98+
}
99+
100+
// Look for [!TYPE] pattern after trimming whitespace
101+
if (contentStart < line.length()) {
102+
String content = line.subSequence(contentStart, line.length()).toString().trim();
103+
Matcher matcher = ALERT_PATTERN.matcher(content);
104+
if (matcher.matches()) {
105+
String type = matcher.group(1);
106+
107+
// Check if this type is allowed (standard or custom)
108+
if (allowedTypes.contains(type)) {
109+
// Skip the entire first line (the marker line) by advancing to the end.
110+
// This is different from regular blockquotes which calculate column position,
111+
// because we want to consume the [!TYPE] marker completely and not render it.
112+
int newColumn = line.length();
113+
114+
return BlockStart.of(new AlertBlockParser(type)).atColumn(newColumn);
115+
}
116+
}
117+
}
118+
119+
return BlockStart.none();
120+
}
121+
}
122+
}

0 commit comments

Comments
 (0)