mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 08:30:25 +08:00 
			
		
		
		
	Add color previews in markdown (#21474)
* Resolves #3047 Every time a color code will be in \`backticks`, a cute little color preview will pop up [Inspiration](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#supported-color-models) #### Before  #### After  Signed-off-by: Yarden Shoham <hrsi88@gmail.com> Co-authored-by: KN4CK3R <admin@oldschoolhack.me> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
		@@ -144,3 +144,39 @@ func IsIcon(node ast.Node) bool {
 | 
			
		||||
	_, ok := node.(*Icon)
 | 
			
		||||
	return ok
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ColorPreview is an inline for a color preview
 | 
			
		||||
type ColorPreview struct {
 | 
			
		||||
	ast.BaseInline
 | 
			
		||||
	Color []byte
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Dump implements Node.Dump.
 | 
			
		||||
func (n *ColorPreview) Dump(source []byte, level int) {
 | 
			
		||||
	m := map[string]string{}
 | 
			
		||||
	m["Color"] = string(n.Color)
 | 
			
		||||
	ast.DumpHelper(n, source, level, m, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// KindColorPreview is the NodeKind for ColorPreview
 | 
			
		||||
var KindColorPreview = ast.NewNodeKind("ColorPreview")
 | 
			
		||||
 | 
			
		||||
// Kind implements Node.Kind.
 | 
			
		||||
func (n *ColorPreview) Kind() ast.NodeKind {
 | 
			
		||||
	return KindColorPreview
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewColorPreview returns a new Span node.
 | 
			
		||||
func NewColorPreview(color []byte) *ColorPreview {
 | 
			
		||||
	return &ColorPreview{
 | 
			
		||||
		BaseInline: ast.BaseInline{},
 | 
			
		||||
		Color:      color,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsColorPreview returns true if the given node implements the ColorPreview interface,
 | 
			
		||||
// otherwise false.
 | 
			
		||||
func IsColorPreview(node ast.Node) bool {
 | 
			
		||||
	_, ok := node.(*ColorPreview)
 | 
			
		||||
	return ok
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,7 @@ import (
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	giteautil "code.gitea.io/gitea/modules/util"
 | 
			
		||||
 | 
			
		||||
	"github.com/microcosm-cc/bluemonday/css"
 | 
			
		||||
	"github.com/yuin/goldmark/ast"
 | 
			
		||||
	east "github.com/yuin/goldmark/extension/ast"
 | 
			
		||||
	"github.com/yuin/goldmark/parser"
 | 
			
		||||
@@ -178,6 +179,11 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
 | 
			
		||||
					v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		case *ast.CodeSpan:
 | 
			
		||||
			colorContent := n.Text(reader.Source())
 | 
			
		||||
			if css.ColorHandler(strings.ToLower(string(colorContent))) {
 | 
			
		||||
				v.AppendChild(v, NewColorPreview(colorContent))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return ast.WalkContinue, nil
 | 
			
		||||
	})
 | 
			
		||||
@@ -266,10 +272,43 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
 | 
			
		||||
	reg.Register(KindDetails, r.renderDetails)
 | 
			
		||||
	reg.Register(KindSummary, r.renderSummary)
 | 
			
		||||
	reg.Register(KindIcon, r.renderIcon)
 | 
			
		||||
	reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
 | 
			
		||||
	reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
 | 
			
		||||
	reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// renderCodeSpan renders CodeSpan elements (like goldmark upstream does) but also renders ColorPreview elements.
 | 
			
		||||
// See #21474 for reference
 | 
			
		||||
func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
 | 
			
		||||
	if entering {
 | 
			
		||||
		if n.Attributes() != nil {
 | 
			
		||||
			_, _ = w.WriteString("<code")
 | 
			
		||||
			html.RenderAttributes(w, n, html.CodeAttributeFilter)
 | 
			
		||||
			_ = w.WriteByte('>')
 | 
			
		||||
		} else {
 | 
			
		||||
			_, _ = w.WriteString("<code>")
 | 
			
		||||
		}
 | 
			
		||||
		for c := n.FirstChild(); c != nil; c = c.NextSibling() {
 | 
			
		||||
			switch v := c.(type) {
 | 
			
		||||
			case *ast.Text:
 | 
			
		||||
				segment := v.Segment
 | 
			
		||||
				value := segment.Value(source)
 | 
			
		||||
				if bytes.HasSuffix(value, []byte("\n")) {
 | 
			
		||||
					r.Writer.RawWrite(w, value[:len(value)-1])
 | 
			
		||||
					r.Writer.RawWrite(w, []byte(" "))
 | 
			
		||||
				} else {
 | 
			
		||||
					r.Writer.RawWrite(w, value)
 | 
			
		||||
				}
 | 
			
		||||
			case *ColorPreview:
 | 
			
		||||
				_, _ = w.WriteString(fmt.Sprintf(`<span class="color-preview" style="background-color: %v"></span>`, string(v.Color)))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return ast.WalkSkipChildren, nil
 | 
			
		||||
	}
 | 
			
		||||
	_, _ = w.WriteString("</code>")
 | 
			
		||||
	return ast.WalkContinue, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
 | 
			
		||||
	n := node.(*ast.Document)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -429,6 +429,61 @@ func TestRenderEmojiInLinks_Issue12331(t *testing.T) {
 | 
			
		||||
	assert.Equal(t, expected, res)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestColorPreview(t *testing.T) {
 | 
			
		||||
	const nl = "\n"
 | 
			
		||||
	positiveTests := []struct {
 | 
			
		||||
		testcase string
 | 
			
		||||
		expected string
 | 
			
		||||
	}{
 | 
			
		||||
		{ // hex
 | 
			
		||||
			"`#FF0000`",
 | 
			
		||||
			`<p><code>#FF0000<span class="color-preview" style="background-color: #FF0000"></span></code></p>` + nl,
 | 
			
		||||
		},
 | 
			
		||||
		{ // rgb
 | 
			
		||||
			"`rgb(16, 32, 64)`",
 | 
			
		||||
			`<p><code>rgb(16, 32, 64)<span class="color-preview" style="background-color: rgb(16, 32, 64)"></span></code></p>` + nl,
 | 
			
		||||
		},
 | 
			
		||||
		{ // short hex
 | 
			
		||||
			"This is the color white `#000`",
 | 
			
		||||
			`<p>This is the color white <code>#000<span class="color-preview" style="background-color: #000"></span></code></p>` + nl,
 | 
			
		||||
		},
 | 
			
		||||
		{ // hsl
 | 
			
		||||
			"HSL stands for hue, saturation, and lightness. An example: `hsl(0, 100%, 50%)`.",
 | 
			
		||||
			`<p>HSL stands for hue, saturation, and lightness. An example: <code>hsl(0, 100%, 50%)<span class="color-preview" style="background-color: hsl(0, 100%, 50%)"></span></code>.</p>` + nl,
 | 
			
		||||
		},
 | 
			
		||||
		{ // uppercase hsl
 | 
			
		||||
			"HSL stands for hue, saturation, and lightness. An example: `HSL(0, 100%, 50%)`.",
 | 
			
		||||
			`<p>HSL stands for hue, saturation, and lightness. An example: <code>HSL(0, 100%, 50%)<span class="color-preview" style="background-color: HSL(0, 100%, 50%)"></span></code>.</p>` + nl,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, test := range positiveTests {
 | 
			
		||||
		res, err := RenderString(&markup.RenderContext{}, test.testcase)
 | 
			
		||||
		assert.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
 | 
			
		||||
		assert.Equal(t, test.expected, res, "Unexpected result in testcase %q", test.testcase)
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	negativeTests := []string{
 | 
			
		||||
		// not a color code
 | 
			
		||||
		"`FF0000`",
 | 
			
		||||
		// inside a code block
 | 
			
		||||
		"```javascript" + nl + `const red = "#FF0000";` + nl + "```",
 | 
			
		||||
		// no backticks
 | 
			
		||||
		"rgb(166, 32, 64)",
 | 
			
		||||
		// typo
 | 
			
		||||
		"`hsI(0, 100%, 50%)`",
 | 
			
		||||
		// looks like a color but not really
 | 
			
		||||
		"`hsl(40, 60, 80)`",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, test := range negativeTests {
 | 
			
		||||
		res, err := RenderString(&markup.RenderContext{}, test)
 | 
			
		||||
		assert.NoError(t, err, "Unexpected error in testcase: %q", test)
 | 
			
		||||
		assert.NotContains(t, res, `<span class="color-preview" style="background-color: `, "Unexpected result in testcase %q", test)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestMathBlock(t *testing.T) {
 | 
			
		||||
	const nl = "\n"
 | 
			
		||||
	testcases := []struct {
 | 
			
		||||
 
 | 
			
		||||
@@ -55,6 +55,9 @@ func createDefaultPolicy() *bluemonday.Policy {
 | 
			
		||||
	// For JS code copy and Mermaid loading state
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
 | 
			
		||||
 | 
			
		||||
	// For color preview
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
 | 
			
		||||
 | 
			
		||||
	// For Chroma markdown plugin
 | 
			
		||||
	policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code")
 | 
			
		||||
 | 
			
		||||
@@ -88,8 +91,8 @@ func createDefaultPolicy() *bluemonday.Policy {
 | 
			
		||||
	// Allow 'style' attribute on text elements.
 | 
			
		||||
	policy.AllowAttrs("style").OnElements("span", "p")
 | 
			
		||||
 | 
			
		||||
	// Allow 'color' property for the style attribute on text elements.
 | 
			
		||||
	policy.AllowStyles("color").OnElements("span", "p")
 | 
			
		||||
	// Allow 'color' and 'background-color' properties for the style attribute on text elements.
 | 
			
		||||
	policy.AllowStyles("color", "background-color").OnElements("span", "p")
 | 
			
		||||
 | 
			
		||||
	// Allow generally safe attributes
 | 
			
		||||
	generalSafeAttrs := []string{
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user