Skip to content

Add GFM alerts extension#420

Open
ia3andy wants to merge 2 commits intocommonmark:mainfrom
ia3andy:gfm-alerts
Open

Add GFM alerts extension#420
ia3andy wants to merge 2 commits intocommonmark:mainfrom
ia3andy:gfm-alerts

Conversation

@ia3andy
Copy link
Copy Markdown
Contributor

@ia3andy ia3andy commented Mar 3, 2026

Note

I have used Claude to code most of it, I am currently reviewing the generated code which seems to be overall valid. I've requested to follow the pattern of existing extensions (and compare to other implementations of this feature).

Summary

  • Adds new extension module for GitHub Flavored Markdown alerts
  • Implements alert detection as a PostProcessor (transforms BlockQuote nodes into Alert nodes after parsing)
  • Includes HTML and Markdown renderers
  • Supports all five standard GFM alert types: NOTE, TIP, IMPORTANT, WARNING, CAUTION
  • Case-insensitive type matching ([!note], [!Note] work like [!NOTE])
  • Supports custom alert types and title overrides via builder API (e.g., for localization)
  • Empty/whitespace-only alerts render as normal blockquotes (matching GitHub behavior)
  • Alerts only at document level (not inside list items or nested blockquotes, matching GitHub)

Test approach

  • AlertsSpecTest — parameterized spec-based tests from alerts-spec.txt, expectations verified against GitHub Markdown API (gh api markdown -f mode=gfm)
  • AlertsTest — custom types, builder validation, AST assertions
  • AlertsMarkdownRendererTest — roundtrip rendering tests

Changes from review

  • Rewrote from custom BlockParser to PostProcessor approach
  • Added case-insensitive type matching
  • Allow overriding standard type titles
  • Removed unnecessary TextContentRenderer (core handles it)
  • Constructor injection for Alert (immutable type, no public setter)
  • Moved STANDARD_TYPES from Alert node to AlertsExtension
  • Removed getCustomTypes() config leak from public API
  • Fixed visitor traversal (early return after successful conversion)
  • Removed dead code (capitalize fallback)

Closes #327

@ia3andy ia3andy marked this pull request as draft March 3, 2026 16:05
@ia3andy ia3andy marked this pull request as ready for review March 3, 2026 16:37
@ia3andy
Copy link
Copy Markdown
Contributor Author

ia3andy commented Mar 3, 2026

I've been through the code which seems good to me so I can say I am taking responsibilities for it like my own. Still, another set of eyes would be good :)

@ia3andy
Copy link
Copy Markdown
Contributor Author

ia3andy commented Mar 21, 2026

@robinst any thought ?

Copy link
Copy Markdown
Collaborator

@robinst robinst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some first comments, mostly around parsing and edge cases.

I was curious how GitHub implements this in cmark-gfm, and the answer seems to be that they don't:

github/cmark-gfm#350 (comment)

Alerts are not implemented in this project. They don't relate to their parser. They don't relate to syntax. So they're not here.

They are implemented as a post processing step on a sort of "DOM" version of the resulting document.

Can you explore what the implementation would look like as a PostProcessor instead? That has the advantage of not having to copy all the parsing logic from block quotes. (From the edge cases with nesting I commented about, it seems like they only look at top-level nodes.)

@ia3andy
Copy link
Copy Markdown
Contributor Author

ia3andy commented Mar 25, 2026

Thanks for the review! That all makes sense, will have a look soonish :)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@ia3andy
Copy link
Copy Markdown
Contributor Author

ia3andy commented Mar 27, 2026

I made all the changes, I am currently generating a small jbang script to generate the spec from gh api result, not needed but cool :)

Add a jbang script (generate-alerts-spec.java) that generates
alerts-spec.txt from a template (alerts-spec-template.md) by rendering
each example through the GitHub Markdown API and normalizing the HTML.

Configure AlertsSpecTest with softbreak("<br>") to match GitHub's
rendering, reducing the normalizations needed in the generator.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@ia3andy
Copy link
Copy Markdown
Contributor Author

ia3andy commented Mar 27, 2026

@robinst it is ready for another review thanks!

Copy link
Copy Markdown
Collaborator

@robinst robinst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More feedback. Getting closer, but I think we can still simplify.

Neat idea to generate the test data by using the gh CLI.

Comment on lines +50 to +51
if (customTypeTitles.containsKey(type)) {
return customTypeTitles.get(type);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (customTypeTitles.containsKey(type)) {
return customTypeTitles.get(type);
var customTypeTitle = customTypeTitles.get(type);
if (customTypeTitle != null) {
return customTypeTitle;
}

public abstract class AlertNodeRenderer implements NodeRenderer {

@Override
public Set<Class<? extends org.commonmark.node.Node>> getNodeTypes() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an import please.

public void visit(BlockQuote blockQuote) {
// Only convert top-level block quotes (direct children of Document).
// This matches GitHub's behavior where alerts are only detected at the document level.
if (blockQuote.getParent() instanceof Document) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do it this way, you're still visiting the full document tree, which is unnecessary.

Just manually iterate over the direct children of Document instead of using an AbstractVisitor please.

}

private boolean tryConvertToAlert(BlockQuote blockQuote) {
Node firstChild = blockQuote.getFirstChild();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Use var here and in other places.

Suggested change
Node firstChild = blockQuote.getFirstChild();
var firstChild = blockQuote.getFirstChild();

String markerText;
Node afterMarker = firstInline.getNext();
if (afterMarker instanceof SoftLineBreak || afterMarker instanceof HardLineBreak || afterMarker == null) {
markerText = literal;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this assignment in here? And having literal as a variable seems unnecessary?

if (!((Text) next).getLiteral().trim().isEmpty()) {
return false;
}
} else if (!(next instanceof SoftLineBreak) && !(next instanceof HardLineBreak)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apart from after the marker, I don't think we can have a soft/hard line break inside a block quote if it's completely empty.

In fact, I don't think we need the logic below either. Or can you show me a test case that breaks if we just check if there's another node present after the marker/line break?

private final Map<String, String> customTypes;

private AlertsExtension(Builder builder) {
this.customTypes = new LinkedHashMap<>(builder.customTypes);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT, no need for a LinkedHashMap here, just a normal HashMap is fine.

if (title == null || title.isEmpty()) {
throw new IllegalArgumentException("Title must not be null or empty");
}
if (!type.equals(type.toUpperCase())) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!type.equals(type.toUpperCase())) {
if (!type.equals(type.toUpperCase(Locale.ROOT))) {

(Otherwise, e.g. in a Turkish locale i will uppercase to İ, which is probably unexpected.)

.build();
```

Custom types must be UPPERCASE and cannot override standard types.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outdated now.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, no need for a LinkedHashMap

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GFM admonition blocks (alerts)

2 participants