mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09: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{ | ||||
|   | ||||
| @@ -1371,6 +1371,14 @@ a.ui.card:hover, | ||||
|   border-color: var(--color-secondary); | ||||
| } | ||||
|  | ||||
| .color-preview { | ||||
|   display: inline-block; | ||||
|   margin-left: .4em; | ||||
|   height: .67em; | ||||
|   width: .67em; | ||||
|   border-radius: .15em; | ||||
| } | ||||
|  | ||||
| footer { | ||||
|   background-color: var(--color-footer); | ||||
|   border-top: 1px solid var(--color-secondary); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user