From 2ff213bbc1ce467c7047e17c65693d82ba87bd25 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 22 Mar 2024 20:16:23 +0800 Subject: [PATCH] Refactor markdown attention render (#29984) Follow #29833 and add tests --- modules/markup/markdown/goldmark.go | 87 +++++-------------- modules/markup/markdown/markdown.go | 2 +- modules/markup/markdown/markdown_test.go | 36 ++++++++ .../markup/markdown/transform_blockquote.go | 67 ++++++++++++++ modules/svg/svg.go | 18 +++- 5 files changed, 145 insertions(+), 65 deletions(-) create mode 100644 modules/markup/markdown/transform_blockquote.go diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index bdb77482470..b61299c4808 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -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) { @@ -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 @@ -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: // @@ -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" { @@ -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 }) @@ -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 { @@ -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) @@ -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. @@ -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 @@ -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 } diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 4cca71d511f..db4e5706f6d 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -126,7 +126,7 @@ func SpecializedMarkdown() goldmark.Markdown { parser.WithAttribute(), parser.WithAutoHeadingID(), parser.WithASTTransformers( - util.Prioritized(&ASTTransformer{}, 10000), + util.Prioritized(NewASTTransformer(), 10000), ), ), goldmark.WithRendererOptions( diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index a12bd4f9e7c..ebac3fbe9e8 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -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 ( @@ -957,3 +960,36 @@ space

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 := `

{Attention}

` + 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

text

\n
") + + test(`> [!note]`, renderAttention("note", "octicon-info")+"\n") + test(`> [!tip]`, renderAttention("tip", "octicon-light-bulb")+"\n") + test(`> [!important]`, renderAttention("important", "octicon-report")+"\n") + test(`> [!warning]`, renderAttention("warning", "octicon-alert")+"\n") + test(`> [!caution]`, renderAttention("caution", "octicon-stop")+"\n") +} diff --git a/modules/markup/markdown/transform_blockquote.go b/modules/markup/markdown/transform_blockquote.go new file mode 100644 index 00000000000..d685cfd1c5a --- /dev/null +++ b/modules/markup/markdown/transform_blockquote.go @@ -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 +} diff --git a/modules/svg/svg.go b/modules/svg/svg.go index 016e1dc08bb..8132978caca 100644 --- a/modules/svg/svg.go +++ b/modules/svg/svg.go @@ -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(``, 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...) @@ -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("%s(%d/%s)", template.HTMLEscapeString(icon), size, template.HTMLEscapeString(class))) }