Skip to content
Open
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
70 changes: 70 additions & 0 deletions commonmark-ext-gfm-alerts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# commonmark-ext-gfm-alerts

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).

Enables highlighting important information using blockquote syntax with five standard alert types: NOTE, TIP, IMPORTANT, WARNING, and CAUTION.

## Usage

#### Markdown Syntax

```markdown
> [!NOTE]
> Useful information

> [!WARNING]
> Critical information
```

#### Standard GFM Types

```java
Extension extension = AlertsExtension.create();
Parser parser = Parser.builder().extensions(List.of(extension)).build();
HtmlRenderer renderer = HtmlRenderer.builder().extensions(List.of(extension)).build();
```

#### Custom Alert Types

Add custom types beyond the five standard GFM types:

```java
Extension extension = AlertsExtension.builder()
.addCustomType("INFO", "Information")
.build();
```

Custom types must be UPPERCASE and cannot override standard types.

#### Styling

Alerts render as `<div>` elements with CSS classes:

```html
<div class="markdown-alert markdown-alert-note" data-alert-type="note">
<p class="markdown-alert-title">Note</p>
<p>Content</p>
</div>
```

Basic CSS example:

```css
.markdown-alert {
padding: 0.5rem 1rem;
margin-bottom: 1rem;
border-left: 4px solid;
}

.markdown-alert-note { border-color: #0969da; background-color: #ddf4ff; }
.markdown-alert-tip { border-color: #1a7f37; background-color: #dcffe4; }
.markdown-alert-important { border-color: #8250df; background-color: #f6f0ff; }
.markdown-alert-warning { border-color: #9a6700; background-color: #fff8c5; }
.markdown-alert-caution { border-color: #cf222e; background-color: #ffebe9; }
```

Icons can be added using CSS `::before` pseudo-elements with GitHub's [Octicons](https://primer.style/octicons/) (info, light-bulb, report, alert, stop icons).

## License

See the main commonmark-java project for license information.
27 changes: 27 additions & 0 deletions commonmark-ext-gfm-alerts/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-parent</artifactId>
<version>0.27.2-SNAPSHOT</version>
</parent>

<artifactId>commonmark-ext-gfm-alerts</artifactId>
<name>commonmark-java extension for alerts</name>
<description>commonmark-java extension for GFM alerts (admonition blocks) using [!TYPE] syntax (GitHub Flavored Markdown)</description>

<dependencies>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
</dependency>

<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-test-util</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
5 changes: 5 additions & 0 deletions commonmark-ext-gfm-alerts/src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module org.commonmark.ext.gfm.alerts {
exports org.commonmark.ext.gfm.alerts;

requires transitive org.commonmark;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.commonmark.ext.gfm.alerts;

import org.commonmark.node.CustomBlock;

import java.util.Set;

/**
* Alert block for highlighting important information using {@code [!TYPE]} syntax.
*/
public class Alert extends CustomBlock {

public static final Set<String> STANDARD_TYPES = Set.of("NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION");

private String type;

public String getType() {
return type;
}

public void setType(String type) {
this.type = type;
}

public boolean isStandardType() {
return STANDARD_TYPES.contains(type);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package org.commonmark.ext.gfm.alerts;

import org.commonmark.Extension;
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.ext.gfm.alerts.internal.AlertTextContentNodeRenderer;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlRenderer;
import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory;
import org.commonmark.renderer.markdown.MarkdownRenderer;
import org.commonmark.renderer.text.TextContentRenderer;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

/**
* Extension for GFM alerts using {@code [!TYPE]} syntax (GitHub Flavored Markdown).
* <p>
* Create with {@link #create()} or {@link #builder()} and configure on builders
* ({@link org.commonmark.parser.Parser.Builder#extensions(Iterable)},
* {@link HtmlRenderer.Builder#extensions(Iterable)}).
* Parsed alerts become {@link Alert} blocks.
*/
public class AlertsExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension,
TextContentRenderer.TextContentRendererExtension, MarkdownRenderer.MarkdownRendererExtension {

private final Map<String, String> customTypes;

private AlertsExtension(Builder builder) {
this.customTypes = new LinkedHashMap<>(builder.customTypes);
}

public static Extension create() {
return new AlertsExtension(builder());
}

public static Builder builder() {
return new Builder();
}

public Map<String, String> getCustomTypes() {
return Collections.unmodifiableMap(customTypes);
}

@Override
public void extend(Parser.Builder parserBuilder) {
parserBuilder.customBlockParserFactory(new AlertBlockParser.Factory(this));
}

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

@Override
public void extend(TextContentRenderer.Builder rendererBuilder) {
rendererBuilder.nodeRendererFactory(AlertTextContentNodeRenderer::new);
}

@Override
public void extend(MarkdownRenderer.Builder rendererBuilder) {
rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() {
@Override
public NodeRenderer create(MarkdownNodeRendererContext context) {
return new AlertMarkdownNodeRenderer(context);
}

@Override
public Set<Character> getSpecialCharacters() {
return Set.of();
}
});
}

/**
* Builder for configuring the alerts extension.
*/
public static class Builder {
private final Map<String, String> customTypes = new LinkedHashMap<>();

/**
* Adds a custom alert type with a display title.
* <p>
* Custom types must:
* <ul>
* <li>Be UPPERCASE (e.g., "INFO", "SUCCESS")</li>
* <li>Not conflict with standard GFM types (NOTE, TIP, IMPORTANT, WARNING, CAUTION)</li>
* </ul>
*
* @param type the alert type (must be uppercase)
* @param title the display title for this alert type
* @return {@code this}
*/
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())) {
throw new IllegalArgumentException("Type must be uppercase: " + type);
}
if (Alert.STANDARD_TYPES.contains(type)) {
throw new IllegalArgumentException("Cannot override standard GFM type: " + type);
}
customTypes.put(type, title);
return this;
}

/**
* @return a configured {@link Extension}
*/
public Extension build() {
return new AlertsExtension(this);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package org.commonmark.ext.gfm.alerts.internal;

import org.commonmark.ext.gfm.alerts.Alert;
import org.commonmark.ext.gfm.alerts.AlertsExtension;
import org.commonmark.node.Block;
import org.commonmark.parser.block.*;
import org.commonmark.text.Characters;

import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class AlertBlockParser extends AbstractBlockParser {

private static final Pattern ALERT_PATTERN = Pattern.compile("^\\[!([A-Z]+)]$");

// Duplicates org.commonmark.internal.util.Parsing.CODE_BLOCK_INDENT which is not accessible
private static final int CODE_BLOCK_INDENT = 4;

private final Alert block = new Alert();

public AlertBlockParser(String type) {
if (type == null) {
throw new IllegalArgumentException("Alert type must not be null");
}
block.setType(type);
}

@Override
public boolean isContainer() {
return true;
}

@Override
public boolean canContain(Block block) {
return true;
}

@Override
public Block getBlock() {
return block;
}

@Override
public BlockContinue tryContinue(ParserState state) {
int nextNonSpace = state.getNextNonSpaceIndex();
if (isMarker(state, nextNonSpace)) {
int newColumn = state.getColumn() + state.getIndent() + 1;
// optional following space or tab
if (Characters.isSpaceOrTab(state.getLine().getContent(), nextNonSpace + 1)) {
newColumn++;
}
return BlockContinue.atColumn(newColumn);
} else {
return BlockContinue.none();
}
}

/**
* Checks if the character at the given index is a blockquote marker ('>').
*
* @param state the parser state
* @param index the index to check
* @return true if the character is '>' and indentation is less than CODE_BLOCK_INDENT
*/
private static boolean isMarker(ParserState state, int index) {
CharSequence line = state.getLine().getContent();
return state.getIndent() < CODE_BLOCK_INDENT && index < line.length() && line.charAt(index) == '>';
}

/**
* Factory for creating alert block parsers.
*/
public static class Factory extends AbstractBlockParserFactory {
private final Set<String> allowedTypes;

public Factory(AlertsExtension extension) {
// Combine standard GFM types with custom types
this.allowedTypes = new HashSet<>(Alert.STANDARD_TYPES);
this.allowedTypes.addAll(extension.getCustomTypes().keySet());
}

@Override
public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) {
int nextNonSpace = state.getNextNonSpaceIndex();
if (!isMarker(state, nextNonSpace)) {
return BlockStart.none();
}

// Check if this is an alert by looking for [!TYPE] pattern
CharSequence line = state.getLine().getContent();
int contentStart = nextNonSpace + 1;
// optional following space or tab
if (Characters.isSpaceOrTab(line, contentStart)) {
contentStart++;
}

// Look for [!TYPE] pattern after trimming whitespace
if (contentStart < line.length()) {
String content = line.subSequence(contentStart, line.length()).toString().trim();
Matcher matcher = ALERT_PATTERN.matcher(content);
if (matcher.matches()) {
String type = matcher.group(1);

// Check if this type is allowed (standard or custom)
if (allowedTypes.contains(type)) {
// Skip the entire first line (the marker line) by advancing to the end.
// This is different from regular blockquotes which calculate column position,
// because we want to consume the [!TYPE] marker completely and not render it.
int newColumn = line.length();

return BlockStart.of(new AlertBlockParser(type)).atColumn(newColumn);
}
}
}

return BlockStart.none();
}
}
}
Loading