Skip to content

Refactor markdown attention render #29984

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 24 additions & 63 deletions modules/markup/markdown/goldmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,21 @@ import (
)

// ASTTransformer is a default transformer of the goldmark tree.
type ASTTransformer struct{}
type ASTTransformer struct {
AttentionTypes container.Set[string]
}

func NewASTTransformer() *ASTTransformer {
return &ASTTransformer{
AttentionTypes: container.SetOf("note", "tip", "important", "warning", "caution"),
}
}

func (g *ASTTransformer) applyElementDir(n ast.Node) {
if markup.DefaultProcessorHelper.ElementDir != "" {
n.SetAttributeString("dir", []byte(markup.DefaultProcessorHelper.ElementDir))
}
}

// Transform transforms the given AST tree.
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
Expand All @@ -45,12 +59,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
tocMode = rc.TOC
}

applyElementDir := func(n ast.Node) {
if markup.DefaultProcessorHelper.ElementDir != "" {
n.SetAttributeString("dir", []byte(markup.DefaultProcessorHelper.ElementDir))
}
}

_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
Expand All @@ -72,9 +80,9 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
header.ID = util.BytesToReadOnlyString(id.([]byte))
}
tocList = append(tocList, header)
applyElementDir(v)
g.applyElementDir(v)
case *ast.Paragraph:
applyElementDir(v)
g.applyElementDir(v)
case *ast.Image:
// Images need two things:
//
Expand Down Expand Up @@ -174,7 +182,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
v.AppendChild(v, newChild)
}
}
applyElementDir(v)
g.applyElementDir(v)
case *ast.Text:
if v.SoftLineBreak() && !v.HardLineBreak() {
if ctx.Metas["mode"] != "document" {
Expand All @@ -189,51 +197,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
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("attention-header attention-"+attentionType))

// create an emphasis to make it bold
attentionParagraph := ast.NewParagraph()
emphasis := ast.NewEmphasis(2)
emphasis.SetAttributeString("class", []byte("attention-"+attentionType))

// capitalize first letter
attentionText := ast.NewString([]byte(strings.ToUpper(string(attentionType[0])) + attentionType[1:]))

// replace the ![TYPE] with a dedicated paragraph of icon+Type
emphasis.AppendChild(emphasis, attentionText)
attentionParagraph.AppendChild(attentionParagraph, NewAttention(attentionType))
attentionParagraph.AppendChild(attentionParagraph, emphasis)
firstParagraph.Parent().InsertBefore(firstParagraph.Parent(), firstParagraph, attentionParagraph)
firstParagraph.RemoveChild(firstParagraph, firstTextNode)
firstParagraph.RemoveChild(firstParagraph, secondTextNode)
firstParagraph.RemoveChild(firstParagraph, thirdTextNode)
return g.transformBlockquote(v, reader)
}
return ast.WalkContinue, nil
})
Expand Down Expand Up @@ -268,7 +232,7 @@ func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
return p.GenerateWithDefault(value, dft)
}

// Generate generates a new element id.
// GenerateWithDefault generates a new element id.
func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte {
result := common.CleanValue(value)
if len(result) == 0 {
Expand Down Expand Up @@ -303,7 +267,8 @@ func newPrefixedIDs() *prefixedIDs {
// in the gitea form.
func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
r := &HTMLRenderer{
Config: html.NewConfig(),
Config: html.NewConfig(),
reValidName: regexp.MustCompile("^[a-z ]+$"),
}
for _, opt := range opts {
opt.SetHTMLOption(&r.Config)
Expand All @@ -315,6 +280,7 @@ func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
// renders gitea specific features.
type HTMLRenderer struct {
html.Config
reValidName *regexp.Regexp
}

// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
Expand Down Expand Up @@ -442,11 +408,6 @@ 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)$")
)

func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
Expand All @@ -461,7 +422,7 @@ func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil
}

if !validNameRE.MatchString(name) {
if !r.reValidName.MatchString(name) {
// skip this
return ast.WalkContinue, nil
}
Expand Down
2 changes: 1 addition & 1 deletion modules/markup/markdown/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func SpecializedMarkdown() goldmark.Markdown {
parser.WithAttribute(),
parser.WithAutoHeadingID(),
parser.WithASTTransformers(
util.Prioritized(&ASTTransformer{}, 10000),
util.Prioritized(NewASTTransformer(), 10000),
),
),
goldmark.WithRendererOptions(
Expand Down
36 changes: 36 additions & 0 deletions modules/markup/markdown/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import (
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/util"

"github.com/stretchr/testify/assert"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

const (
Expand Down Expand Up @@ -957,3 +960,36 @@ space</p>
assert.Equal(t, template.HTML(c.Expected), result, "Unexpected result in testcase %v", i)
}
}

func TestAttention(t *testing.T) {
defer svg.MockIcon("octicon-info")()
defer svg.MockIcon("octicon-light-bulb")()
defer svg.MockIcon("octicon-report")()
defer svg.MockIcon("octicon-alert")()
defer svg.MockIcon("octicon-stop")()

renderAttention := func(attention, icon string) string {
tmpl := `<blockquote class="attention-header attention-{attention}"><p><svg class="attention-icon attention-{attention} svg {icon}" width="16" height="16"></svg><strong class="attention-{attention}">{Attention}</strong></p>`
tmpl = strings.ReplaceAll(tmpl, "{attention}", attention)
tmpl = strings.ReplaceAll(tmpl, "{icon}", icon)
tmpl = strings.ReplaceAll(tmpl, "{Attention}", cases.Title(language.English).String(attention))
return tmpl
}

test := func(input, expected string) {
result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background()}, input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(result)))
}

test(`
> [!NOTE]
> text
`, renderAttention("note", "octicon-info")+"\n<p>text</p>\n</blockquote>")

test(`> [!note]`, renderAttention("note", "octicon-info")+"\n</blockquote>")
test(`> [!tip]`, renderAttention("tip", "octicon-light-bulb")+"\n</blockquote>")
test(`> [!important]`, renderAttention("important", "octicon-report")+"\n</blockquote>")
test(`> [!warning]`, renderAttention("warning", "octicon-alert")+"\n</blockquote>")
test(`> [!caution]`, renderAttention("caution", "octicon-stop")+"\n</blockquote>")
}
67 changes: 67 additions & 0 deletions modules/markup/markdown/transform_blockquote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package markdown

import (
"strings"

"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

func (g *ASTTransformer) transformBlockquote(v *ast.Blockquote, reader text.Reader) (ast.WalkStatus, error) {
// We only want attention blockquotes when the AST looks like:
// > Text("[") Text("!TYPE") Text("]")

// grab these nodes and make sure we adhere to the attention blockquote structure
firstParagraph := v.FirstChild()
g.applyElementDir(firstParagraph)
if firstParagraph.ChildCount() < 3 {
return ast.WalkContinue, nil
}
node1, ok1 := firstParagraph.FirstChild().(*ast.Text)
node2, ok2 := node1.NextSibling().(*ast.Text)
node3, ok3 := node2.NextSibling().(*ast.Text)
if !ok1 || !ok2 || !ok3 {
return ast.WalkContinue, nil
}
val1 := string(node1.Segment.Value(reader.Source()))
val2 := string(node2.Segment.Value(reader.Source()))
val3 := string(node3.Segment.Value(reader.Source()))
if val1 != "[" || val3 != "]" || !strings.HasPrefix(val2, "!") {
return ast.WalkContinue, nil
}

// grab attention type from markdown source
attentionType := strings.ToLower(val2[1:])
if !g.AttentionTypes.Contains(attentionType) {
return ast.WalkContinue, nil
}

// color the blockquote
v.SetAttributeString("class", []byte("attention-header attention-"+attentionType))

// create an emphasis to make it bold
attentionParagraph := ast.NewParagraph()
g.applyElementDir(attentionParagraph)
emphasis := ast.NewEmphasis(2)
emphasis.SetAttributeString("class", []byte("attention-"+attentionType))

attentionAstString := ast.NewString([]byte(cases.Title(language.English).String(attentionType)))

// replace the ![TYPE] with a dedicated paragraph of icon+Type
emphasis.AppendChild(emphasis, attentionAstString)
attentionParagraph.AppendChild(attentionParagraph, NewAttention(attentionType))
attentionParagraph.AppendChild(attentionParagraph, emphasis)
firstParagraph.Parent().InsertBefore(firstParagraph.Parent(), firstParagraph, attentionParagraph)
firstParagraph.RemoveChild(firstParagraph, node1)
firstParagraph.RemoveChild(firstParagraph, node2)
firstParagraph.RemoveChild(firstParagraph, node3)
if firstParagraph.ChildCount() == 0 {
firstParagraph.Parent().RemoveChild(firstParagraph.Parent(), firstParagraph)
}
return ast.WalkContinue, nil
}
18 changes: 17 additions & 1 deletion modules/svg/svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ func Init() error {
return nil
}

func MockIcon(icon string) func() {
if svgIcons == nil {
svgIcons = make(map[string]string)
}
orig, exist := svgIcons[icon]
svgIcons[icon] = fmt.Sprintf(`<svg class="svg %s" width="%d" height="%d"></svg>`, icon, defaultSize, defaultSize)
return func() {
if exist {
svgIcons[icon] = orig
} else {
delete(svgIcons, icon)
}
}
}

// RenderHTML renders icons - arguments icon name (string), size (int), class (string)
func RenderHTML(icon string, others ...any) template.HTML {
size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...)
Expand All @@ -55,5 +70,6 @@ func RenderHTML(icon string, others ...any) template.HTML {
}
return template.HTML(svgStr)
}
return ""
// during test (or something wrong happens), there is no SVG loaded, so use a dummy span to tell that the icon is missing
return template.HTML(fmt.Sprintf("<span>%s(%d/%s)</span>", template.HTMLEscapeString(icon), size, template.HTMLEscapeString(class)))
}