Refactor markdown attention render (#29984)
Follow #29833 and add tests
This commit is contained in:
parent
6845717158
commit
2ff213bbc1
@ -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 {
|
||||
@ -304,6 +268,7 @@ func newPrefixedIDs() *prefixedIDs {
|
||||
func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||
r := &HTMLRenderer{
|
||||
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
|
||||
}
|
@ -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...)
|
||||
@ -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)))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user