* 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 ![image](https://user-images.githubusercontent.com/20454870/196631524-298afbbf-d2c8-4018-92a5-0393a693d850.png) #### After ![image](https://user-images.githubusercontent.com/20454870/196631397-36c561e4-08f5-465a-a36e-76084e30b08a.png) 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>tags/v1.18.0-rc0
@@ -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); |