Skip to content

Commit e828564

Browse files
yardenshohamKN4CK3Rsilverwindlunny
authored
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 ![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>
1 parent 16cbd5b commit e828564

File tree

5 files changed

+143
-2
lines changed

5 files changed

+143
-2
lines changed

modules/markup/markdown/ast.go

+36
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,39 @@ func IsIcon(node ast.Node) bool {
144144
_, ok := node.(*Icon)
145145
return ok
146146
}
147+
148+
// ColorPreview is an inline for a color preview
149+
type ColorPreview struct {
150+
ast.BaseInline
151+
Color []byte
152+
}
153+
154+
// Dump implements Node.Dump.
155+
func (n *ColorPreview) Dump(source []byte, level int) {
156+
m := map[string]string{}
157+
m["Color"] = string(n.Color)
158+
ast.DumpHelper(n, source, level, m, nil)
159+
}
160+
161+
// KindColorPreview is the NodeKind for ColorPreview
162+
var KindColorPreview = ast.NewNodeKind("ColorPreview")
163+
164+
// Kind implements Node.Kind.
165+
func (n *ColorPreview) Kind() ast.NodeKind {
166+
return KindColorPreview
167+
}
168+
169+
// NewColorPreview returns a new Span node.
170+
func NewColorPreview(color []byte) *ColorPreview {
171+
return &ColorPreview{
172+
BaseInline: ast.BaseInline{},
173+
Color: color,
174+
}
175+
}
176+
177+
// IsColorPreview returns true if the given node implements the ColorPreview interface,
178+
// otherwise false.
179+
func IsColorPreview(node ast.Node) bool {
180+
_, ok := node.(*ColorPreview)
181+
return ok
182+
}

modules/markup/markdown/goldmark.go

+39
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"code.gitea.io/gitea/modules/setting"
1717
giteautil "code.gitea.io/gitea/modules/util"
1818

19+
"github.com/microcosm-cc/bluemonday/css"
1920
"github.com/yuin/goldmark/ast"
2021
east "github.com/yuin/goldmark/extension/ast"
2122
"github.com/yuin/goldmark/parser"
@@ -178,6 +179,11 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
178179
v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments)
179180
}
180181
}
182+
case *ast.CodeSpan:
183+
colorContent := n.Text(reader.Source())
184+
if css.ColorHandler(strings.ToLower(string(colorContent))) {
185+
v.AppendChild(v, NewColorPreview(colorContent))
186+
}
181187
}
182188
return ast.WalkContinue, nil
183189
})
@@ -266,10 +272,43 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
266272
reg.Register(KindDetails, r.renderDetails)
267273
reg.Register(KindSummary, r.renderSummary)
268274
reg.Register(KindIcon, r.renderIcon)
275+
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
269276
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
270277
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
271278
}
272279

280+
// renderCodeSpan renders CodeSpan elements (like goldmark upstream does) but also renders ColorPreview elements.
281+
// See #21474 for reference
282+
func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
283+
if entering {
284+
if n.Attributes() != nil {
285+
_, _ = w.WriteString("<code")
286+
html.RenderAttributes(w, n, html.CodeAttributeFilter)
287+
_ = w.WriteByte('>')
288+
} else {
289+
_, _ = w.WriteString("<code>")
290+
}
291+
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
292+
switch v := c.(type) {
293+
case *ast.Text:
294+
segment := v.Segment
295+
value := segment.Value(source)
296+
if bytes.HasSuffix(value, []byte("\n")) {
297+
r.Writer.RawWrite(w, value[:len(value)-1])
298+
r.Writer.RawWrite(w, []byte(" "))
299+
} else {
300+
r.Writer.RawWrite(w, value)
301+
}
302+
case *ColorPreview:
303+
_, _ = w.WriteString(fmt.Sprintf(`<span class="color-preview" style="background-color: %v"></span>`, string(v.Color)))
304+
}
305+
}
306+
return ast.WalkSkipChildren, nil
307+
}
308+
_, _ = w.WriteString("</code>")
309+
return ast.WalkContinue, nil
310+
}
311+
273312
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
274313
n := node.(*ast.Document)
275314

modules/markup/markdown/markdown_test.go

+55
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,61 @@ func TestRenderEmojiInLinks_Issue12331(t *testing.T) {
429429
assert.Equal(t, expected, res)
430430
}
431431

432+
func TestColorPreview(t *testing.T) {
433+
const nl = "\n"
434+
positiveTests := []struct {
435+
testcase string
436+
expected string
437+
}{
438+
{ // hex
439+
"`#FF0000`",
440+
`<p><code>#FF0000<span class="color-preview" style="background-color: #FF0000"></span></code></p>` + nl,
441+
},
442+
{ // rgb
443+
"`rgb(16, 32, 64)`",
444+
`<p><code>rgb(16, 32, 64)<span class="color-preview" style="background-color: rgb(16, 32, 64)"></span></code></p>` + nl,
445+
},
446+
{ // short hex
447+
"This is the color white `#000`",
448+
`<p>This is the color white <code>#000<span class="color-preview" style="background-color: #000"></span></code></p>` + nl,
449+
},
450+
{ // hsl
451+
"HSL stands for hue, saturation, and lightness. An example: `hsl(0, 100%, 50%)`.",
452+
`<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,
453+
},
454+
{ // uppercase hsl
455+
"HSL stands for hue, saturation, and lightness. An example: `HSL(0, 100%, 50%)`.",
456+
`<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,
457+
},
458+
}
459+
460+
for _, test := range positiveTests {
461+
res, err := RenderString(&markup.RenderContext{}, test.testcase)
462+
assert.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
463+
assert.Equal(t, test.expected, res, "Unexpected result in testcase %q", test.testcase)
464+
465+
}
466+
467+
negativeTests := []string{
468+
// not a color code
469+
"`FF0000`",
470+
// inside a code block
471+
"```javascript" + nl + `const red = "#FF0000";` + nl + "```",
472+
// no backticks
473+
"rgb(166, 32, 64)",
474+
// typo
475+
"`hsI(0, 100%, 50%)`",
476+
// looks like a color but not really
477+
"`hsl(40, 60, 80)`",
478+
}
479+
480+
for _, test := range negativeTests {
481+
res, err := RenderString(&markup.RenderContext{}, test)
482+
assert.NoError(t, err, "Unexpected error in testcase: %q", test)
483+
assert.NotContains(t, res, `<span class="color-preview" style="background-color: `, "Unexpected result in testcase %q", test)
484+
}
485+
}
486+
432487
func TestMathBlock(t *testing.T) {
433488
const nl = "\n"
434489
testcases := []struct {

modules/markup/sanitizer.go

+5-2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ func createDefaultPolicy() *bluemonday.Policy {
5555
// For JS code copy and Mermaid loading state
5656
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
5757

58+
// For color preview
59+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
60+
5861
// For Chroma markdown plugin
5962
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code")
6063

@@ -88,8 +91,8 @@ func createDefaultPolicy() *bluemonday.Policy {
8891
// Allow 'style' attribute on text elements.
8992
policy.AllowAttrs("style").OnElements("span", "p")
9093

91-
// Allow 'color' property for the style attribute on text elements.
92-
policy.AllowStyles("color").OnElements("span", "p")
94+
// Allow 'color' and 'background-color' properties for the style attribute on text elements.
95+
policy.AllowStyles("color", "background-color").OnElements("span", "p")
9396

9497
// Allow generally safe attributes
9598
generalSafeAttrs := []string{

web_src/less/_base.less

+8
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,14 @@ a.ui.card:hover,
13711371
border-color: var(--color-secondary);
13721372
}
13731373

1374+
.color-preview {
1375+
display: inline-block;
1376+
margin-left: .4em;
1377+
height: .67em;
1378+
width: .67em;
1379+
border-radius: .15em;
1380+
}
1381+
13741382
footer {
13751383
background-color: var(--color-footer);
13761384
border-top: 1px solid var(--color-secondary);

0 commit comments

Comments
 (0)