mirror of https://github.com/go-gitea/gitea.git
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>
This commit is contained in:
parent
16cbd5b59c
commit
e828564445
|
@ -144,3 +144,39 @@ func IsIcon(node ast.Node) bool {
|
||||||
_, ok := node.(*Icon)
|
_, ok := node.(*Icon)
|
||||||
return ok
|
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"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
giteautil "code.gitea.io/gitea/modules/util"
|
giteautil "code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
|
"github.com/microcosm-cc/bluemonday/css"
|
||||||
"github.com/yuin/goldmark/ast"
|
"github.com/yuin/goldmark/ast"
|
||||||
east "github.com/yuin/goldmark/extension/ast"
|
east "github.com/yuin/goldmark/extension/ast"
|
||||||
"github.com/yuin/goldmark/parser"
|
"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)
|
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
|
return ast.WalkContinue, nil
|
||||||
})
|
})
|
||||||
|
@ -266,10 +272,43 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
reg.Register(KindDetails, r.renderDetails)
|
reg.Register(KindDetails, r.renderDetails)
|
||||||
reg.Register(KindSummary, r.renderSummary)
|
reg.Register(KindSummary, r.renderSummary)
|
||||||
reg.Register(KindIcon, r.renderIcon)
|
reg.Register(KindIcon, r.renderIcon)
|
||||||
|
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||||
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
|
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
|
||||||
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
|
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) {
|
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
n := node.(*ast.Document)
|
n := node.(*ast.Document)
|
||||||
|
|
||||||
|
|
|
@ -429,6 +429,61 @@ func TestRenderEmojiInLinks_Issue12331(t *testing.T) {
|
||||||
assert.Equal(t, expected, res)
|
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) {
|
func TestMathBlock(t *testing.T) {
|
||||||
const nl = "\n"
|
const nl = "\n"
|
||||||
testcases := []struct {
|
testcases := []struct {
|
||||||
|
|
|
@ -55,6 +55,9 @@ func createDefaultPolicy() *bluemonday.Policy {
|
||||||
// For JS code copy and Mermaid loading state
|
// For JS code copy and Mermaid loading state
|
||||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre")
|
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
|
// For Chroma markdown plugin
|
||||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code")
|
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.
|
// Allow 'style' attribute on text elements.
|
||||||
policy.AllowAttrs("style").OnElements("span", "p")
|
policy.AllowAttrs("style").OnElements("span", "p")
|
||||||
|
|
||||||
// Allow 'color' property for the style attribute on text elements.
|
// Allow 'color' and 'background-color' properties for the style attribute on text elements.
|
||||||
policy.AllowStyles("color").OnElements("span", "p")
|
policy.AllowStyles("color", "background-color").OnElements("span", "p")
|
||||||
|
|
||||||
// Allow generally safe attributes
|
// Allow generally safe attributes
|
||||||
generalSafeAttrs := []string{
|
generalSafeAttrs := []string{
|
||||||
|
|
|
@ -1371,6 +1371,14 @@ a.ui.card:hover,
|
||||||
border-color: var(--color-secondary);
|
border-color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: .4em;
|
||||||
|
height: .67em;
|
||||||
|
width: .67em;
|
||||||
|
border-radius: .15em;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
background-color: var(--color-footer);
|
background-color: var(--color-footer);
|
||||||
border-top: 1px solid var(--color-secondary);
|
border-top: 1px solid var(--color-secondary);
|
||||||
|
|
Loading…
Reference in New Issue