mirror of
https://github.com/theonedev/onedev.git
synced 2025-12-08 18:26:30 +00:00
feat: Support Markdown callouts (OD-2561)
This commit is contained in:
parent
0defe8b62a
commit
aeb94bc272
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user