From aeb94bc272be464a559d28cde87bc7b4b392b367 Mon Sep 17 00:00:00 2001 From: Robin Shen Date: Thu, 25 Sep 2025 22:33:20 +0800 Subject: [PATCH] feat: Support Markdown callouts (OD-2561) --- .../server/markdown/CalloutBlockParser.java | 130 ++++++++++++++++ .../server/markdown/CalloutExtension.java | 48 ++++++ .../server/markdown/CalloutHtmlRenderer.java | 108 ++++++++++++++ .../onedev/server/markdown/CalloutNode.java | 91 +++++++++++ .../markdown/DefaultMarkdownManager.java | 1 + .../java/io/onedev/server/util/HtmlUtils.java | 3 +- .../server/web/asset/callout/callout.css | 141 ++++++++++++++++++ .../web/component/markdown/markdown.css | 13 ++ 8 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 server-core/src/main/java/io/onedev/server/markdown/CalloutBlockParser.java create mode 100644 server-core/src/main/java/io/onedev/server/markdown/CalloutExtension.java create mode 100644 server-core/src/main/java/io/onedev/server/markdown/CalloutHtmlRenderer.java create mode 100644 server-core/src/main/java/io/onedev/server/markdown/CalloutNode.java create mode 100644 server-core/src/main/java/io/onedev/server/web/asset/callout/callout.css diff --git a/server-core/src/main/java/io/onedev/server/markdown/CalloutBlockParser.java b/server-core/src/main/java/io/onedev/server/markdown/CalloutBlockParser.java new file mode 100644 index 0000000000..8d8828ac28 --- /dev/null +++ b/server-core/src/main/java/io/onedev/server/markdown/CalloutBlockParser.java @@ -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> getAfterDependents() { + return null; + } + + @Override + public Set> 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(); + } + }; + } + } +} diff --git a/server-core/src/main/java/io/onedev/server/markdown/CalloutExtension.java b/server-core/src/main/java/io/onedev/server/markdown/CalloutExtension.java new file mode 100644 index 0000000000..a0473a1957 --- /dev/null +++ b/server-core/src/main/java/io/onedev/server/markdown/CalloutExtension.java @@ -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()); + } + } +} diff --git a/server-core/src/main/java/io/onedev/server/markdown/CalloutHtmlRenderer.java b/server-core/src/main/java/io/onedev/server/markdown/CalloutHtmlRenderer.java new file mode 100644 index 0000000000..cf2d319991 --- /dev/null +++ b/server-core/src/main/java/io/onedev/server/markdown/CalloutHtmlRenderer.java @@ -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> getNodeRenderingHandlers() { + HashSet> 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(""); + 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); + } + } +} diff --git a/server-core/src/main/java/io/onedev/server/markdown/CalloutNode.java b/server-core/src/main/java/io/onedev/server/markdown/CalloutNode.java new file mode 100644 index 0000000000..5c9fc7eebf --- /dev/null +++ b/server-core/src/main/java/io/onedev/server/markdown/CalloutNode.java @@ -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 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); + } + } + +} diff --git a/server-core/src/main/java/io/onedev/server/markdown/DefaultMarkdownManager.java b/server-core/src/main/java/io/onedev/server/markdown/DefaultMarkdownManager.java index 31e0be2224..412938b9cb 100644 --- a/server-core/src/main/java/io/onedev/server/markdown/DefaultMarkdownManager.java +++ b/server-core/src/main/java/io/onedev/server/markdown/DefaultMarkdownManager.java @@ -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() diff --git a/server-core/src/main/java/io/onedev/server/util/HtmlUtils.java b/server-core/src/main/java/io/onedev/server/util/HtmlUtils.java index ef86ac448b..10e6b7cd95 100644 --- a/server-core/src/main/java/io/onedev/server/util/HtmlUtils.java +++ b/server-core/src/main/java/io/onedev/server/util/HtmlUtils.java @@ -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") diff --git a/server-core/src/main/java/io/onedev/server/web/asset/callout/callout.css b/server-core/src/main/java/io/onedev/server/web/asset/callout/callout.css new file mode 100644 index 0000000000..416602e906 --- /dev/null +++ b/server-core/src/main/java/io/onedev/server/web/asset/callout/callout.css @@ -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; + } +} diff --git a/server-core/src/main/java/io/onedev/server/web/component/markdown/markdown.css b/server-core/src/main/java/io/onedev/server/web/component/markdown/markdown.css index 20e832ee5d..48d9fd4f1a 100644 --- a/server-core/src/main/java/io/onedev/server/web/component/markdown/markdown.css +++ b/server-core/src/main/java/io/onedev/server/web/component/markdown/markdown.css @@ -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; } \ No newline at end of file