mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Refactor markdown attention render (#29984)
Follow #29833 and add tests
This commit is contained in:
		| @@ -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 | ||||
| 	} | ||||
|   | ||||
| @@ -126,7 +126,7 @@ func SpecializedMarkdown() goldmark.Markdown { | ||||
| 				parser.WithAttribute(), | ||||
| 				parser.WithAutoHeadingID(), | ||||
| 				parser.WithASTTransformers( | ||||
| 					util.Prioritized(&ASTTransformer{}, 10000), | ||||
| 					util.Prioritized(NewASTTransformer(), 10000), | ||||
| 				), | ||||
| 			), | ||||
| 			goldmark.WithRendererOptions( | ||||
|   | ||||
| @@ -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</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
									
								
								modules/markup/markdown/transform_blockquote.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								modules/markup/markdown/transform_blockquote.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
		Reference in New Issue
	
	Block a user