From 0b3193bbbe952460e3498f5b8cae21059a22e646 Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Sat, 10 Feb 2024 20:43:09 +0200 Subject: [PATCH 1/4] Add alert blocks in markdown (#29121) - Follows https://github.com/go-gitea/gitea/pull/21711 - Closes https://github.com/go-gitea/gitea/issues/28316 Implement GitHub's alert blocks markdown feature Docs: - https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts - https://github.com/orgs/community/discussions/16925 ### Before ![image](https://github.com/go-gitea/gitea/assets/20454870/14f7b02a-5de5-4fd0-8437-a055dadb31f2) ### After ![image](https://github.com/go-gitea/gitea/assets/20454870/ed06a869-e545-42f1-bf25-4ba20b1be196) ## :warning: BREAKING :warning: The old syntax no longer works How to migrate: If you used ```md > **Note** My note ``` Switch to ```md > [!NOTE] > My note ``` --------- Signed-off-by: Yarden Shoham Co-authored-by: silverwind Co-authored-by: Giteabot --- modules/markup/markdown/ast.go | 7 +-- modules/markup/markdown/goldmark.go | 77 +++++++++++++++++++++++------ modules/markup/sanitizer.go | 5 +- web_src/css/base.css | 39 ++++++++++++--- 4 files changed, 97 insertions(+), 31 deletions(-) diff --git a/modules/markup/markdown/ast.go b/modules/markup/markdown/ast.go index 3e6e291ab2..77ce5cb359 100644 --- a/modules/markup/markdown/ast.go +++ b/modules/markup/markdown/ast.go @@ -182,12 +182,7 @@ func IsColorPreview(node ast.Node) bool { return ok } -const ( - AttentionNote string = "Note" - AttentionWarning string = "Warning" -) - -// Attention is an inline for a color preview +// Attention is an inline for an attention type Attention struct { ast.BaseInline AttentionType string diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 178e3d2fdd..36ce6397f4 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -53,7 +53,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa } } - attentionMarkedBlockquotes := make(container.Set[*ast.Blockquote]) _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil @@ -197,18 +196,55 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa if css.ColorHandler(strings.ToLower(string(colorContent))) { v.AppendChild(v, NewColorPreview(colorContent)) } - case *ast.Emphasis: - // check if inside blockquote for attention, expected hierarchy is - // Emphasis < Paragraph < Blockquote - blockquote, isInBlockquote := n.Parent().Parent().(*ast.Blockquote) - if isInBlockquote && !attentionMarkedBlockquotes.Contains(blockquote) { - fullText := string(n.Text(reader.Source())) - if fullText == AttentionNote || fullText == AttentionWarning { - v.SetAttributeString("class", []byte("attention-"+strings.ToLower(fullText))) - v.Parent().InsertBefore(v.Parent(), v, NewAttention(fullText)) - attentionMarkedBlockquotes.Add(blockquote) - } + case *ast.Blockquote: + // We only want attention blockquotes when the AST looks like: + // Text: "[" + // Text: "!TYPE" + // Text(SoftLineBreak): "]" + + // grab these nodes and make sure we adhere to the attention blockquote structure + firstParagraph := v.FirstChild() + if firstParagraph.ChildCount() < 3 { + return ast.WalkContinue, nil } + firstTextNode, ok := firstParagraph.FirstChild().(*ast.Text) + if !ok || string(firstTextNode.Segment.Value(reader.Source())) != "[" { + return ast.WalkContinue, nil + } + secondTextNode, ok := firstTextNode.NextSibling().(*ast.Text) + if !ok || !attentionTypeRE.MatchString(string(secondTextNode.Segment.Value(reader.Source()))) { + return ast.WalkContinue, nil + } + thirdTextNode, ok := secondTextNode.NextSibling().(*ast.Text) + if !ok || string(thirdTextNode.Segment.Value(reader.Source())) != "]" { + return ast.WalkContinue, nil + } + + // grab attention type from markdown source + attentionType := strings.ToLower(strings.TrimPrefix(string(secondTextNode.Segment.Value(reader.Source())), "!")) + + // color the blockquote + v.SetAttributeString("class", []byte("gt-py-3 attention attention-"+attentionType)) + + // create an emphasis to make it bold + emphasis := ast.NewEmphasis(2) + emphasis.SetAttributeString("class", []byte("attention-"+attentionType)) + firstParagraph.InsertBefore(firstParagraph, firstTextNode, emphasis) + + // capitalize first letter + attentionText := ast.NewString([]byte(strings.ToUpper(string(attentionType[0])) + attentionType[1:])) + + // replace the ![TYPE] with icon+Type + emphasis.AppendChild(emphasis, attentionText) + for i := 0; i < 2; i++ { + lineBreak := ast.NewText() + lineBreak.SetSoftLineBreak(true) + firstParagraph.InsertAfter(firstParagraph, emphasis, lineBreak) + } + firstParagraph.InsertBefore(firstParagraph, emphasis, NewAttention(attentionType)) + firstParagraph.RemoveChild(firstParagraph, firstTextNode) + firstParagraph.RemoveChild(firstParagraph, secondTextNode) + firstParagraph.RemoveChild(firstParagraph, thirdTextNode) } return ast.WalkContinue, nil }) @@ -339,17 +375,23 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod // renderAttention renders a quote marked with i.e. "> **Note**" or "> **Warning**" with a corresponding svg func (r *HTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { - _, _ = w.WriteString(``) var octiconType string switch n.AttentionType { - case AttentionNote: + case "note": octiconType = "info" - case AttentionWarning: + case "tip": + octiconType = "light-bulb" + case "important": + octiconType = "report" + case "warning": octiconType = "alert" + case "caution": + octiconType = "stop" } _, _ = w.WriteString(string(svg.RenderHTML("octicon-" + octiconType))) } else { @@ -417,7 +459,10 @@ func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.N return ast.WalkContinue, nil } -var validNameRE = regexp.MustCompile("^[a-z ]+$") +var ( + validNameRE = regexp.MustCompile("^[a-z ]+$") + attentionTypeRE = regexp.MustCompile("^!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)$") +) func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go index 992e85b989..ffc33c3b8e 100644 --- a/modules/markup/sanitizer.go +++ b/modules/markup/sanitizer.go @@ -64,9 +64,10 @@ func createDefaultPolicy() *bluemonday.Policy { policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span") // For attention + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^gt-py-3 attention attention-\w+$`)).OnElements("blockquote") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-\w+$`)).OnElements("strong") - policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-icon attention-\w+$`)).OnElements("span", "strong") - policy.AllowAttrs("class").Matching(regexp.MustCompile(`^svg octicon-\w+$`)).OnElements("svg") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^gt-mr-2 gt-vm attention-\w+$`)).OnElements("span", "strong") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^svg octicon-(\w|-)+$`)).OnElements("svg") policy.AllowAttrs("viewBox", "width", "height", "aria-hidden").OnElements("svg") policy.AllowAttrs("fill-rule", "d").OnElements("path") diff --git a/web_src/css/base.css b/web_src/css/base.css index 198e87c0e2..ea32aac6f7 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -1268,20 +1268,45 @@ img.ui.avatar, border-radius: var(--border-radius); } -.attention-icon { - vertical-align: text-top; +.attention { + color: var(--color-text) !important; } -.attention-note { - font-weight: unset; - color: var(--color-info-text); +blockquote.attention-note { + border-left-color: var(--color-blue-dark-1); +} +strong.attention-note, span.attention-note { + color: var(--color-blue-dark-1); } -.attention-warning { - font-weight: unset; +blockquote.attention-tip { + border-left-color: var(--color-success-text); +} +strong.attention-tip, span.attention-tip { + color: var(--color-success-text); +} + +blockquote.attention-important { + border-left-color: var(--color-violet-dark-1); +} +strong.attention-important, span.attention-important { + color: var(--color-violet-dark-1); +} + +blockquote.attention-warning { + border-left-color: var(--color-warning-text); +} +strong.attention-warning, span.attention-warning { color: var(--color-warning-text); } +blockquote.attention-caution { + border-left-color: var(--color-red-dark-1); +} +strong.attention-caution, span.attention-caution { + color: var(--color-red-dark-1); +} + .center:not(.popup) { text-align: center; } From a177a5c4cbad62cd6930be24ad5d50ae24938797 Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Sat, 17 Feb 2024 17:36:07 +0100 Subject: [PATCH 2/4] modules/markup: Lift out the GitHub callout transformer This lifts out the GitHub callout transformer from `modules/markup/markdown/goldmark.go` to `callout/github.go`. While there, clean up the transformer code: - Use a map to look up supported callout types, rather than a regexp. - Allow the callout type to be in any case, rather than just uppercase. - Simplified `.Segment.Value()` to `.Text()`. Signed-off-by: Gergely Nagy --- modules/markup/markdown/ast.go | 29 ----- modules/markup/markdown/callout/ast.go | 37 ++++++ modules/markup/markdown/callout/github.go | 142 ++++++++++++++++++++++ modules/markup/markdown/goldmark.go | 84 +------------ modules/markup/markdown/markdown.go | 3 + 5 files changed, 183 insertions(+), 112 deletions(-) create mode 100644 modules/markup/markdown/callout/ast.go create mode 100644 modules/markup/markdown/callout/github.go diff --git a/modules/markup/markdown/ast.go b/modules/markup/markdown/ast.go index 77ce5cb359..72d32600f5 100644 --- a/modules/markup/markdown/ast.go +++ b/modules/markup/markdown/ast.go @@ -181,32 +181,3 @@ func IsColorPreview(node ast.Node) bool { _, ok := node.(*ColorPreview) return ok } - -// Attention is an inline for an attention -type Attention struct { - ast.BaseInline - AttentionType string -} - -// Dump implements Node.Dump. -func (n *Attention) Dump(source []byte, level int) { - m := map[string]string{} - m["AttentionType"] = n.AttentionType - ast.DumpHelper(n, source, level, m, nil) -} - -// KindAttention is the NodeKind for Attention -var KindAttention = ast.NewNodeKind("Attention") - -// Kind implements Node.Kind. -func (n *Attention) Kind() ast.NodeKind { - return KindAttention -} - -// NewAttention returns a new Attention node. -func NewAttention(attentionType string) *Attention { - return &Attention{ - BaseInline: ast.BaseInline{}, - AttentionType: attentionType, - } -} diff --git a/modules/markup/markdown/callout/ast.go b/modules/markup/markdown/callout/ast.go new file mode 100644 index 0000000000..a5b1bbc2a0 --- /dev/null +++ b/modules/markup/markdown/callout/ast.go @@ -0,0 +1,37 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package callout + +import ( + "github.com/yuin/goldmark/ast" +) + +// Attention is an inline for an attention +type Attention struct { + ast.BaseInline + AttentionType string +} + +// Dump implements Node.Dump. +func (n *Attention) Dump(source []byte, level int) { + m := map[string]string{} + m["AttentionType"] = n.AttentionType + ast.DumpHelper(n, source, level, m, nil) +} + +// KindAttention is the NodeKind for Attention +var KindAttention = ast.NewNodeKind("Attention") + +// Kind implements Node.Kind. +func (n *Attention) Kind() ast.NodeKind { + return KindAttention +} + +// NewAttention returns a new Attention node. +func NewAttention(attentionType string) *Attention { + return &Attention{ + BaseInline: ast.BaseInline{}, + AttentionType: attentionType, + } +} diff --git a/modules/markup/markdown/callout/github.go b/modules/markup/markdown/callout/github.go new file mode 100644 index 0000000000..58f1fc960f --- /dev/null +++ b/modules/markup/markdown/callout/github.go @@ -0,0 +1,142 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package callout + +import ( + "strings" + + "code.gitea.io/gitea/modules/svg" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type GitHubCalloutTransformer struct{} + +// Transform transforms the given AST tree. +func (g *GitHubCalloutTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + supportedAttentionTypes := map[string]bool{ + "note": true, + "tip": true, + "important": true, + "warning": true, + "caution": true, + } + + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch v := n.(type) { + case *ast.Blockquote: + // We only want attention blockquotes when the AST looks like: + // Text: "[" + // Text: "!TYPE" + // Text(SoftLineBreak): "]" + + // grab these nodes and make sure we adhere to the attention blockquote structure + firstParagraph := v.FirstChild() + if firstParagraph.ChildCount() < 3 { + return ast.WalkContinue, nil + } + firstTextNode, ok := firstParagraph.FirstChild().(*ast.Text) + if !ok || string(firstTextNode.Text(reader.Source())) != "[" { + return ast.WalkContinue, nil + } + secondTextNode, ok := firstTextNode.NextSibling().(*ast.Text) + if !ok { + return ast.WalkContinue, nil + } + // If the second node's text isn't one of the supported attention + // types, continue walking. + secondTextNodeText := secondTextNode.Text(reader.Source()) + attentionType := strings.ToLower(strings.TrimPrefix(string(secondTextNodeText), "!")) + if _, has := supportedAttentionTypes[attentionType]; !has { + return ast.WalkContinue, nil + } + + thirdTextNode, ok := secondTextNode.NextSibling().(*ast.Text) + if !ok || string(thirdTextNode.Text(reader.Source())) != "]" { + return ast.WalkContinue, nil + } + + // color the blockquote + v.SetAttributeString("class", []byte("gt-py-3 attention attention-"+attentionType)) + + // create an emphasis to make it bold + emphasis := ast.NewEmphasis(2) + emphasis.SetAttributeString("class", []byte("attention-"+attentionType)) + firstParagraph.InsertBefore(firstParagraph, firstTextNode, emphasis) + + // capitalize first letter + attentionText := ast.NewString([]byte(strings.ToUpper(string(attentionType[0])) + attentionType[1:])) + + // replace the ![TYPE] with icon+Type + emphasis.AppendChild(emphasis, attentionText) + for i := 0; i < 2; i++ { + lineBreak := ast.NewText() + lineBreak.SetSoftLineBreak(true) + firstParagraph.InsertAfter(firstParagraph, emphasis, lineBreak) + } + firstParagraph.InsertBefore(firstParagraph, emphasis, NewAttention(attentionType)) + firstParagraph.RemoveChild(firstParagraph, firstTextNode) + firstParagraph.RemoveChild(firstParagraph, secondTextNode) + firstParagraph.RemoveChild(firstParagraph, thirdTextNode) + } + return ast.WalkContinue, nil + }) +} + +type GitHubCalloutHTMLRenderer struct { + html.Config +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *GitHubCalloutHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindAttention, r.renderAttention) +} + +// renderAttention renders a quote marked with i.e. "> **Note**" or "> **Warning**" with a corresponding svg +func (r *GitHubCalloutHTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(``) + + var octiconType string + switch n.AttentionType { + case "note": + octiconType = "info" + case "tip": + octiconType = "light-bulb" + case "important": + octiconType = "report" + case "warning": + octiconType = "alert" + case "caution": + octiconType = "stop" + } + _, _ = w.WriteString(string(svg.RenderHTML("octicon-" + octiconType))) + } else { + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func NewGitHubCalloutHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &GitHubCalloutHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 36ce6397f4..da2224ce0c 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -13,7 +13,6 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/common" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/svg" giteautil "code.gitea.io/gitea/modules/util" "github.com/microcosm-cc/bluemonday/css" @@ -196,55 +195,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa if css.ColorHandler(strings.ToLower(string(colorContent))) { v.AppendChild(v, NewColorPreview(colorContent)) } - case *ast.Blockquote: - // We only want attention blockquotes when the AST looks like: - // Text: "[" - // Text: "!TYPE" - // Text(SoftLineBreak): "]" - - // grab these nodes and make sure we adhere to the attention blockquote structure - firstParagraph := v.FirstChild() - if firstParagraph.ChildCount() < 3 { - return ast.WalkContinue, nil - } - firstTextNode, ok := firstParagraph.FirstChild().(*ast.Text) - if !ok || string(firstTextNode.Segment.Value(reader.Source())) != "[" { - return ast.WalkContinue, nil - } - secondTextNode, ok := firstTextNode.NextSibling().(*ast.Text) - if !ok || !attentionTypeRE.MatchString(string(secondTextNode.Segment.Value(reader.Source()))) { - return ast.WalkContinue, nil - } - thirdTextNode, ok := secondTextNode.NextSibling().(*ast.Text) - if !ok || string(thirdTextNode.Segment.Value(reader.Source())) != "]" { - return ast.WalkContinue, nil - } - - // grab attention type from markdown source - attentionType := strings.ToLower(strings.TrimPrefix(string(secondTextNode.Segment.Value(reader.Source())), "!")) - - // color the blockquote - v.SetAttributeString("class", []byte("gt-py-3 attention attention-"+attentionType)) - - // create an emphasis to make it bold - emphasis := ast.NewEmphasis(2) - emphasis.SetAttributeString("class", []byte("attention-"+attentionType)) - firstParagraph.InsertBefore(firstParagraph, firstTextNode, emphasis) - - // capitalize first letter - attentionText := ast.NewString([]byte(strings.ToUpper(string(attentionType[0])) + attentionType[1:])) - - // replace the ![TYPE] with icon+Type - emphasis.AppendChild(emphasis, attentionText) - for i := 0; i < 2; i++ { - lineBreak := ast.NewText() - lineBreak.SetSoftLineBreak(true) - firstParagraph.InsertAfter(firstParagraph, emphasis, lineBreak) - } - firstParagraph.InsertBefore(firstParagraph, emphasis, NewAttention(attentionType)) - firstParagraph.RemoveChild(firstParagraph, firstTextNode) - firstParagraph.RemoveChild(firstParagraph, secondTextNode) - firstParagraph.RemoveChild(firstParagraph, thirdTextNode) } return ast.WalkContinue, nil }) @@ -335,7 +285,6 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(KindSummary, r.renderSummary) reg.Register(KindIcon, r.renderIcon) reg.Register(ast.KindCodeSpan, r.renderCodeSpan) - reg.Register(KindAttention, r.renderAttention) reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem) reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox) } @@ -372,34 +321,6 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod return ast.WalkContinue, nil } -// renderAttention renders a quote marked with i.e. "> **Note**" or "> **Warning**" with a corresponding svg -func (r *HTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - _, _ = w.WriteString(``) - - var octiconType string - switch n.AttentionType { - case "note": - octiconType = "info" - case "tip": - octiconType = "light-bulb" - case "important": - octiconType = "report" - case "warning": - octiconType = "alert" - case "caution": - octiconType = "stop" - } - _, _ = w.WriteString(string(svg.RenderHTML("octicon-" + octiconType))) - } else { - _, _ = w.WriteString("\n") - } - return ast.WalkContinue, nil -} - func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { n := node.(*ast.Document) @@ -459,10 +380,7 @@ func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.N return ast.WalkContinue, nil } -var ( - validNameRE = regexp.MustCompile("^[a-z ]+$") - attentionTypeRE = regexp.MustCompile("^!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)$") -) +var validNameRE = regexp.MustCompile("^[a-z ]+$") func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 771162b9a3..92c0e786e9 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/markup/markdown/callout" "code.gitea.io/gitea/modules/markup/markdown/math" "code.gitea.io/gitea/modules/setting" giteautil "code.gitea.io/gitea/modules/util" @@ -124,6 +125,7 @@ func SpecializedMarkdown() goldmark.Markdown { parser.WithAttribute(), parser.WithAutoHeadingID(), parser.WithASTTransformers( + util.Prioritized(&callout.GitHubCalloutTransformer{}, 9000), util.Prioritized(&ASTTransformer{}, 10000), ), ), @@ -135,6 +137,7 @@ func SpecializedMarkdown() goldmark.Markdown { // Override the original Tasklist renderer! specMarkdown.Renderer().AddOptions( renderer.WithNodeRenderers( + util.Prioritized(callout.NewGitHubCalloutHTMLRenderer(), 10), util.Prioritized(NewHTMLRenderer(), 10), ), ) From b594188623f2afd0bc61eb69fb3cc26b5c47859d Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Sat, 17 Feb 2024 17:36:36 +0100 Subject: [PATCH 3/4] Restore compatibility with the legacy GitHub callout syntax Although GitHub removed support for the legacy callout syntax, we don't have to! Restore this support via another AST transformer. Signed-off-by: Gergely Nagy --- .../markup/markdown/callout/github_legacy.go | 60 +++++++++++++++++++ modules/markup/markdown/markdown.go | 1 + 2 files changed, 61 insertions(+) create mode 100644 modules/markup/markdown/callout/github_legacy.go diff --git a/modules/markup/markdown/callout/github_legacy.go b/modules/markup/markdown/callout/github_legacy.go new file mode 100644 index 0000000000..add6b0a847 --- /dev/null +++ b/modules/markup/markdown/callout/github_legacy.go @@ -0,0 +1,60 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package callout + +import ( + "strings" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// Transformer for GitHub's legacy callout markup. +type GitHubLegacyCalloutTransformer struct{} + +func (g *GitHubLegacyCalloutTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + supportedCalloutTypes := map[string]bool{"Note": true, "Warning": true} + + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch v := n.(type) { + case *ast.Blockquote: + // The first paragraph contains the callout type. + firstParagraph := v.FirstChild() + if firstParagraph.ChildCount() < 1 { + return ast.WalkContinue, nil + } + + // In the legacy GitHub callout markup, the first node of the first + // paragraph should be an emphasis. + calloutNode, ok := firstParagraph.FirstChild().(*ast.Emphasis) + if !ok { + return ast.WalkContinue, nil + } + calloutText := string(calloutNode.Text(reader.Source())) + calloutType := strings.ToLower(calloutText) + // We only support "Note" and "Warning" callouts in legacy mode, + // match only those. + if _, has := supportedCalloutTypes[calloutText]; !has { + return ast.WalkContinue, nil + } + + // Set the attention attribute on the emphasis + calloutNode.SetAttributeString("class", []byte("attention-"+calloutType)) + + // color the blockquote + v.SetAttributeString("class", []byte("gt-py-3 attention attention-"+calloutType)) + + // Prepend callout icon before the callout node itself + firstParagraph.InsertBefore(firstParagraph, calloutNode, NewAttention(calloutType)) + } + + return ast.WalkContinue, nil + }) +} diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 92c0e786e9..00d01a2f55 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -125,6 +125,7 @@ func SpecializedMarkdown() goldmark.Markdown { parser.WithAttribute(), parser.WithAutoHeadingID(), parser.WithASTTransformers( + util.Prioritized(&callout.GitHubLegacyCalloutTransformer{}, 8000), util.Prioritized(&callout.GitHubCalloutTransformer{}, 9000), util.Prioritized(&ASTTransformer{}, 10000), ), From cc47808b84a9cec52b49a995ca0c03654f0b1db8 Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Sat, 17 Feb 2024 17:55:26 +0100 Subject: [PATCH 4/4] tests: Markdown alert block test cases This adds a few test cases to exercise the alert block feature of the markdown renderer, both the legacy GitHub style, and the modern one. Signed-off-by: Gergely Nagy --- tests/integration/markup_test.go | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/integration/markup_test.go diff --git a/tests/integration/markup_test.go b/tests/integration/markup_test.go new file mode 100644 index 0000000000..b054abdaae --- /dev/null +++ b/tests/integration/markup_test.go @@ -0,0 +1,72 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRenderAlertBlocks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteMisc) + + assertAlertBlock := func(t *testing.T, input, alertType, alertIcon string) { + t.Helper() + + blockquoteAttr := fmt.Sprintf(`
**%s** +> +> This is a %s.`, alertType, alertType) + + assertAlertBlock(t, input, alertType, alertIcon) + }) + } + }) + + t.Run("modern style", func(t *testing.T) { + for alertType, alertIcon := range map[string]string{ + "NOTE": "info", + "TIP": "light-bulb", + "IMPORTANT": "report", + "WARNING": "alert", + "CAUTION": "stop", + } { + t.Run(alertType, func(t *testing.T) { + input := fmt.Sprintf(`> [!%s] +> +> This is a %s.`, alertType, alertType) + + assertAlertBlock(t, input, alertType, alertIcon) + }) + } + }) +}