mirror of https://github.com/go-gitea/gitea.git
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.
|
// 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.
|
// Transform transforms the given AST tree.
|
||||||
func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
|
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
|
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) {
|
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
if !entering {
|
if !entering {
|
||||||
return ast.WalkContinue, nil
|
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))
|
header.ID = util.BytesToReadOnlyString(id.([]byte))
|
||||||
}
|
}
|
||||||
tocList = append(tocList, header)
|
tocList = append(tocList, header)
|
||||||
applyElementDir(v)
|
g.applyElementDir(v)
|
||||||
case *ast.Paragraph:
|
case *ast.Paragraph:
|
||||||
applyElementDir(v)
|
g.applyElementDir(v)
|
||||||
case *ast.Image:
|
case *ast.Image:
|
||||||
// Images need two things:
|
// Images need two things:
|
||||||
//
|
//
|
||||||
|
@ -174,7 +182,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
|
||||||
v.AppendChild(v, newChild)
|
v.AppendChild(v, newChild)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyElementDir(v)
|
g.applyElementDir(v)
|
||||||
case *ast.Text:
|
case *ast.Text:
|
||||||
if v.SoftLineBreak() && !v.HardLineBreak() {
|
if v.SoftLineBreak() && !v.HardLineBreak() {
|
||||||
if ctx.Metas["mode"] != "document" {
|
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))
|
v.AppendChild(v, NewColorPreview(colorContent))
|
||||||
}
|
}
|
||||||
case *ast.Blockquote:
|
case *ast.Blockquote:
|
||||||
// We only want attention blockquotes when the AST looks like:
|
return g.transformBlockquote(v, reader)
|
||||||
// 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 ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
})
|
})
|
||||||
|
@ -268,7 +232,7 @@ func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
|
||||||
return p.GenerateWithDefault(value, dft)
|
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 {
|
func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte {
|
||||||
result := common.CleanValue(value)
|
result := common.CleanValue(value)
|
||||||
if len(result) == 0 {
|
if len(result) == 0 {
|
||||||
|
@ -303,7 +267,8 @@ func newPrefixedIDs() *prefixedIDs {
|
||||||
// in the gitea form.
|
// in the gitea form.
|
||||||
func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||||
r := &HTMLRenderer{
|
r := &HTMLRenderer{
|
||||||
Config: html.NewConfig(),
|
Config: html.NewConfig(),
|
||||||
|
reValidName: regexp.MustCompile("^[a-z ]+$"),
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt.SetHTMLOption(&r.Config)
|
opt.SetHTMLOption(&r.Config)
|
||||||
|
@ -315,6 +280,7 @@ func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||||
// renders gitea specific features.
|
// renders gitea specific features.
|
||||||
type HTMLRenderer struct {
|
type HTMLRenderer struct {
|
||||||
html.Config
|
html.Config
|
||||||
|
reValidName *regexp.Regexp
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
|
// 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
|
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) {
|
func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
if !entering {
|
if !entering {
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
|
@ -461,7 +422,7 @@ func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !validNameRE.MatchString(name) {
|
if !r.reValidName.MatchString(name) {
|
||||||
// skip this
|
// skip this
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -126,7 +126,7 @@ func SpecializedMarkdown() goldmark.Markdown {
|
||||||
parser.WithAttribute(),
|
parser.WithAttribute(),
|
||||||
parser.WithAutoHeadingID(),
|
parser.WithAutoHeadingID(),
|
||||||
parser.WithASTTransformers(
|
parser.WithASTTransformers(
|
||||||
util.Prioritized(&ASTTransformer{}, 10000),
|
util.Prioritized(NewASTTransformer(), 10000),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
goldmark.WithRendererOptions(
|
goldmark.WithRendererOptions(
|
||||||
|
|
|
@ -16,9 +16,12 @@ import (
|
||||||
"code.gitea.io/gitea/modules/markup"
|
"code.gitea.io/gitea/modules/markup"
|
||||||
"code.gitea.io/gitea/modules/markup/markdown"
|
"code.gitea.io/gitea/modules/markup/markdown"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/svg"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -957,3 +960,36 @@ space</p>
|
||||||
assert.Equal(t, template.HTML(c.Expected), result, "Unexpected result in testcase %v", i)
|
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>")
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
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)
|
// RenderHTML renders icons - arguments icon name (string), size (int), class (string)
|
||||||
func RenderHTML(icon string, others ...any) template.HTML {
|
func RenderHTML(icon string, others ...any) template.HTML {
|
||||||
size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...)
|
size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...)
|
||||||
|
@ -55,5 +70,6 @@ func RenderHTML(icon string, others ...any) template.HTML {
|
||||||
}
|
}
|
||||||
return template.HTML(svgStr)
|
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