aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorYarden Shoham <hrsi88@gmail.com>2022-10-21 15:00:53 +0300
committerGitHub <noreply@github.com>2022-10-21 20:00:53 +0800
commite828564445ba5856747f17faf2ac6b1a223a911d (patch)
treefd1aa958dca6a08d3da518133027f3e7d5ea5d25
parent16cbd5b59ccba3e418ba0c4c345eb2778ef1d15a (diff)
downloadgitea-e828564445ba5856747f17faf2ac6b1a223a911d.tar.gz
gitea-e828564445ba5856747f17faf2ac6b1a223a911d.zip
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>
-rw-r--r--modules/markup/markdown/ast.go36
-rw-r--r--modules/markup/markdown/goldmark.go39
-rw-r--r--modules/markup/markdown/markdown_test.go55
-rw-r--r--modules/markup/sanitizer.go7
-rw-r--r--web_src/less/_base.less8
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);