feat: Support Markdown callouts (OD-2561)

This commit is contained in:
Robin Shen 2025-09-25 22:33:20 +08:00
parent 0defe8b62a
commit aeb94bc272
8 changed files with 534 additions and 1 deletions

View File

@ -0,0 +1,130 @@
package io.onedev.server.markdown;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.vladsch.flexmark.parser.block.AbstractBlockParser;
import com.vladsch.flexmark.parser.block.BlockContinue;
import com.vladsch.flexmark.parser.block.BlockParser;
import com.vladsch.flexmark.parser.block.BlockParserFactory;
import com.vladsch.flexmark.parser.block.BlockStart;
import com.vladsch.flexmark.parser.block.CustomBlockParserFactory;
import com.vladsch.flexmark.parser.block.MatchedBlockParser;
import com.vladsch.flexmark.parser.block.ParserState;
import com.vladsch.flexmark.parser.core.BlockQuoteParser;
import com.vladsch.flexmark.util.ast.Block;
import com.vladsch.flexmark.util.ast.BlockContent;
import com.vladsch.flexmark.util.data.DataHolder;
import com.vladsch.flexmark.util.sequence.BasedSequence;
/**
* Parser for GFM callout blocks.
*
* Recognizes patterns like:
* > [!NOTE] Optional title
* > Content
*/
public class CalloutBlockParser extends AbstractBlockParser {
private static final Pattern CALLOUT_PATTERN = Pattern.compile(
"^\\s*>\\s*\\[!([A-Za-z]+)\\](?:\\s+(.*))?\\s*$"
);
private final CalloutNode block = new CalloutNode();
private BlockContent content = new BlockContent();
public CalloutBlockParser(DataHolder options, BasedSequence calloutType, BasedSequence calloutTitle) {
block.setCalloutType(calloutType);
block.setCalloutTitle(calloutTitle);
}
@Override
public CalloutNode getBlock() {
return block;
}
@Override
public BlockContinue tryContinue(ParserState state) {
BasedSequence line = state.getLine();
// Check if line starts with > (blockquote continuation)
if (line.length() > 0 && line.charAt(0) == '>') {
int nextNonSpace = state.getNextNonSpaceIndex();
if (nextNonSpace < line.length() && line.charAt(nextNonSpace) == '>') {
int contentStart = nextNonSpace + 1;
if (contentStart < line.length() && line.charAt(contentStart) == ' ') {
contentStart++;
}
return BlockContinue.atIndex(contentStart);
}
}
return BlockContinue.none();
}
@Override
public void addLine(ParserState state, BasedSequence line) {
content.add(line, state.getIndent());
}
@Override
public void closeBlock(ParserState state) {
block.setContent(content.getLines());
content = null;
}
@Override
public boolean isContainer() {
return true;
}
@Override
public boolean canContain(ParserState state, BlockParser blockParser, Block block) {
return true;
}
public static class Factory implements CustomBlockParserFactory {
@Override
public Set<Class<?>> getAfterDependents() {
return null;
}
@Override
public Set<Class<?>> getBeforeDependents() {
// Parse before blockquote parser
return Set.of(BlockQuoteParser.class);
}
@Override
public boolean affectsGlobalScope() {
return false;
}
@Override
public BlockParserFactory apply(DataHolder options) {
return new BlockParserFactory() {
@Override
public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) {
BasedSequence line = state.getLine();
Matcher matcher = CALLOUT_PATTERN.matcher(line);
if (matcher.matches()) {
String calloutTitle = matcher.group(2);
BasedSequence calloutTypeSeq = line.subSequence(matcher.start(1), matcher.end(1));
BasedSequence calloutTitleSeq = calloutTitle != null ?
line.subSequence(matcher.start(2), matcher.end(2)) :
BasedSequence.NULL;
return BlockStart.of(new CalloutBlockParser(options, calloutTypeSeq, calloutTitleSeq))
.atIndex(line.length());
}
return BlockStart.none();
}
};
}
}
}

View File

@ -0,0 +1,48 @@
package io.onedev.server.markdown;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.MutableDataHolder;
import com.vladsch.flexmark.util.misc.Extension;
/**
* Flexmark extension to support GitHub Flavored Markdown (GFM) callouts.
*
* Supports callout syntax like:
* > [!NOTE]
* > This is a note
*
* > [!WARNING]
* > This is a warning
*/
public class CalloutExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension {
private CalloutExtension() {
}
public static Extension create() {
return new CalloutExtension();
}
@Override
public void rendererOptions(MutableDataHolder options) {
// No renderer options needed
}
@Override
public void parserOptions(MutableDataHolder options) {
// No parser options needed
}
@Override
public void extend(Parser.Builder parserBuilder) {
parserBuilder.customBlockParserFactory(new CalloutBlockParser.Factory());
}
@Override
public void extend(HtmlRenderer.Builder htmlRendererBuilder, String rendererType) {
if (htmlRendererBuilder.isRendererType("HTML")) {
htmlRendererBuilder.nodeRendererFactory(new CalloutHtmlRenderer.Factory());
}
}
}

View File

@ -0,0 +1,108 @@
package io.onedev.server.markdown;
import java.util.HashSet;
import java.util.Set;
import com.vladsch.flexmark.html.HtmlWriter;
import com.vladsch.flexmark.html.renderer.NodeRenderer;
import com.vladsch.flexmark.html.renderer.NodeRendererContext;
import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
import com.vladsch.flexmark.util.data.DataHolder;
import io.onedev.server.web.component.svg.SpriteImage;
/**
* HTML renderer for callout blocks.
*
* Renders callouts as styled div elements with appropriate CSS classes and icons.
*/
public class CalloutHtmlRenderer implements NodeRenderer {
public CalloutHtmlRenderer(DataHolder options) {
// Constructor for options if needed
}
@Override
public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() {
HashSet<NodeRenderingHandler<?>> set = new HashSet<>();
set.add(new NodeRenderingHandler<>(CalloutNode.class, this::render));
return set;
}
private void render(CalloutNode node, NodeRendererContext context, HtmlWriter html) {
String calloutType = node.getCalloutTypeString();
String calloutTitle = node.getCalloutTitleString();
// Get CSS class and icon for the callout type
String cssClass = getCalloutCssClass(calloutType);
String icon = getCalloutIcon(calloutType);
html.line();
html.withAttr().attr("class", "callout callout-" + calloutType + " " + cssClass).tag("div");
html.line();
// Render header with icon and title
html.withAttr().attr("class", "callout-header h4").tag("div");
if (icon != null) {
html.withAttr().attr("class", "callout-icon mr-2").tag("span");
html.raw("<svg class='icon icon-lg'><use xlink:href='" + SpriteImage.getVersionedHref(icon) + "'/></svg>");
html.tag("/span");
}
html.withAttr().attr("class", "callout-title").tag("span");
html.text(calloutTitle);
html.tag("/span");
html.tag("/div");
html.line();
// Render content
html.withAttr().attr("class", "callout-content").tag("div");
context.renderChildren(node);
html.tag("/div");
html.line();
html.tag("/div");
html.line();
}
private String getCalloutCssClass(String type) {
switch (type) {
case "note":
return "alert alert-light-primary alert-notice";
case "tip":
return "alert alert-light-success alert-notice";
case "important":
return "alert alert-light-info alert-notice";
case "warning":
return "alert alert-light-warning alert-notice";
case "caution":
return "alert alert-light-danger alert-notice";
default:
return "alert alert-light alert-notice";
}
}
private String getCalloutIcon(String type) {
switch (type) {
case "note":
return "info";
case "tip":
return "bulb";
case "important":
return "bell-ring";
case "warning":
return "warning";
case "caution":
return "exclamation-circle";
default:
return "hand";
}
}
public static class Factory implements NodeRendererFactory {
@Override
public NodeRenderer apply(DataHolder options) {
return new CalloutHtmlRenderer(options);
}
}
}

View File

@ -0,0 +1,91 @@
package io.onedev.server.markdown;
import java.util.List;
import com.vladsch.flexmark.util.ast.Block;
import com.vladsch.flexmark.util.ast.BlockContent;
import com.vladsch.flexmark.util.sequence.BasedSequence;
/**
* AST node representing a callout block.
*
* A callout block is a blockquote with a special syntax like:
* > [!NOTE]
* > Content of the callout
*/
public class CalloutNode extends Block {
private BasedSequence calloutType = BasedSequence.NULL;
private BasedSequence calloutTitle = BasedSequence.NULL;
public CalloutNode() {
}
public CalloutNode(BasedSequence chars) {
super(chars);
}
public CalloutNode(BasedSequence chars, List<BasedSequence> segments) {
super(chars, segments);
}
public CalloutNode(BlockContent blockContent) {
super(blockContent);
}
public BasedSequence[] getSegments() {
return new BasedSequence[] { calloutType, calloutTitle };
}
public void getAstExtra(StringBuilder out) {
if (!calloutType.isNull()) {
out.append(" type:").append(calloutType);
}
if (!calloutTitle.isNull()) {
out.append(" title:").append(calloutTitle);
}
}
public BasedSequence getCalloutType() {
return calloutType;
}
public void setCalloutType(BasedSequence calloutType) {
this.calloutType = calloutType;
}
public BasedSequence getCalloutTitle() {
return calloutTitle;
}
public void setCalloutTitle(BasedSequence calloutTitle) {
this.calloutTitle = calloutTitle;
}
public String getCalloutTypeString() {
return calloutType.toString().toLowerCase();
}
public String getCalloutTitleString() {
return calloutTitle.isNull() ? getDefaultTitle() : calloutTitle.toString();
}
private String getDefaultTitle() {
String type = getCalloutTypeString();
switch (type) {
case "note":
return "Note";
case "tip":
return "Tip";
case "important":
return "Important";
case "warning":
return "Warning";
case "caution":
return "Caution";
default:
return type.substring(0, 1).toUpperCase() + type.substring(1);
}
}
}

View File

@ -59,6 +59,7 @@ public class DefaultMarkdownManager implements MarkdownManager {
extensions.add(TocExtension.create());
extensions.add(AutolinkExtension.create());
extensions.add(GitLabExtension.create());
extensions.add(CalloutExtension.create());
extensions.addAll(contributedExtensions);
return new MutableDataSet()

View File

@ -15,7 +15,7 @@ public class HtmlUtils {
"i", "strong", "em", "a", "pre", "code", "img", "tt", "div", "ins", "del", "sup", "sub", "p", "ol", "ul",
"li", "table", "thead", "tbody", "tfoot", "th", "tr", "td", "rt", "rp", "blockquote", "dl", "dt", "dd",
"kbd", "q", "hr", "strike", "caption", "cite", "col", "colgroup", "small", "span", "u", "input", "video",
"source", "details", "summary"};
"source", "details", "summary", "svg", "use"};
private static final String[] SAFE_ATTRIBUTES = new String[] { "abbr", "accept", "accept-charset", "accesskey",
"action", "align", "alt", "axis", "border", "cellpadding", "cellspacing", "char", "charoff", "charset",
@ -48,6 +48,7 @@ public class HtmlUtils {
.addAttributes("img", "align", "alt", "height", "src", "title", "width")
.addAttributes("div", "itemscope", "itemtype")
.addAttributes("source", "src")
.addAttributes("use", "xlink:href")
.addAttributes(":all", SAFE_ATTRIBUTES)
.addProtocols("a", "href", SAFE_ANCHOR_SCHEMES)
.addProtocols("blockquote", "cite", "http", "https")

View File

@ -0,0 +1,141 @@
/* GFM Callout Styles */
.callout {
margin: 1rem 0;
padding: 0;
border-left: 4px solid;
border-radius: 0.375rem;
overflow: hidden;
}
.callout-header {
padding: 0.75rem 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.callout-content {
padding: 0 1rem 0.75rem 1rem;
}
.callout-content > *:first-child {
margin-top: 0;
}
.callout-content > *:last-child {
margin-bottom: 0;
}
.callout-icon {
display: inline-flex;
align-items: center;
}
/* Note callout - Blue */
.callout-note {
background-color: #f0f8ff;
border-left-color: #0969da;
}
.callout-note .callout-header {
background-color: rgba(9, 105, 218, 0.1);
color: #0969da;
}
/* Tip callout - Green */
.callout-tip {
background-color: #f0fff4;
border-left-color: #1a7f37;
}
.callout-tip .callout-header {
background-color: rgba(26, 127, 55, 0.1);
color: #1a7f37;
}
/* Important callout - Purple */
.callout-important {
background-color: #fdf7ff;
border-left-color: #8250df;
}
.callout-important .callout-header {
background-color: rgba(130, 80, 223, 0.1);
color: #8250df;
}
/* Warning callout - Orange */
.callout-warning {
background-color: #fff8f0;
border-left-color: #bf8700;
}
.callout-warning .callout-header {
background-color: rgba(191, 135, 0, 0.1);
color: #bf8700;
}
/* Caution callout - Red */
.callout-caution {
background-color: #fff5f5;
border-left-color: #d1242f;
}
.callout-caution .callout-header {
background-color: rgba(209, 36, 47, 0.1);
color: #d1242f;
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.callout-note {
background-color: rgba(9, 105, 218, 0.1);
border-left-color: #58a6ff;
}
.callout-note .callout-header {
background-color: rgba(88, 166, 255, 0.15);
color: #58a6ff;
}
.callout-tip {
background-color: rgba(26, 127, 55, 0.1);
border-left-color: #3fb950;
}
.callout-tip .callout-header {
background-color: rgba(63, 185, 80, 0.15);
color: #3fb950;
}
.callout-important {
background-color: rgba(130, 80, 223, 0.1);
border-left-color: #a5a5ff;
}
.callout-important .callout-header {
background-color: rgba(165, 165, 255, 0.15);
color: #a5a5ff;
}
.callout-warning {
background-color: rgba(191, 135, 0, 0.1);
border-left-color: #d29922;
}
.callout-warning .callout-header {
background-color: rgba(210, 153, 34, 0.15);
color: #d29922;
}
.callout-caution {
background-color: rgba(209, 36, 47, 0.1);
border-left-color: #f85149;
}
.callout-caution .callout-header {
background-color: rgba(248, 81, 73, 0.15);
color: #f85149;
}
}

View File

@ -471,4 +471,17 @@
.markdown-action-menu {
min-width: 320px !important;
}
.markdown-rendered .callout {
background-color: transparent !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.markdown-rendered .callout-content {
color: var(--gray-dark) !important;
}
.dark-mode .markdown-rendered .callout-content {
color: var(--dark-mode-gray) !important;
}