diff options
-rw-r--r-- | modules/markup/markdown/ast.go | 36 | ||||
-rw-r--r-- | modules/markup/markdown/goldmark.go | 39 | ||||
-rw-r--r-- | modules/markup/markdown/markdown_test.go | 55 | ||||
-rw-r--r-- | modules/markup/sanitizer.go | 7 | ||||
-rw-r--r-- | web_src/less/_base.less | 8 |
5 files changed, 143 insertions, 2 deletions
diff --git a/modules/markup/markdown/ast.go b/modules/markup/markdown/ast.go index 5191d94cdd..c82d5e5e73 100644 --- a/modules/markup/markdown/ast.go +++ b/modules/markup/markdown/ast.go @@ -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 +} diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 8417019ddb..1a36681366 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -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) diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index 49ed3d75d6..12c6288c24 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -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 { diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go index 807a8a7892..ff7165c131 100644 --- a/modules/markup/sanitizer.go +++ b/modules/markup/sanitizer.go @@ -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{ diff --git a/web_src/less/_base.less b/web_src/less/_base.less index bfc6e0cf96..b255f81d7a 100644 --- a/web_src/less/_base.less +++ b/web_src/less/_base.less @@ -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); |