Skip to content

Commit

Permalink
Add color previews in markdown (#21474)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
4 people committed Oct 21, 2022
1 parent 16cbd5b commit e828564
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 2 deletions.
36 changes: 36 additions & 0 deletions modules/markup/markdown/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
39 changes: 39 additions & 0 deletions modules/markup/markdown/goldmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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)

Expand Down
55 changes: 55 additions & 0 deletions modules/markup/markdown/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions modules/markup/sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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{
Expand Down
8 changes: 8 additions & 0 deletions web_src/less/_base.less
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit e828564

Please sign in to comment.