Fix markdown frontmatter rendering (#34102)

Fix #34101
pull/34100/head^2
wxiaoguang 2025-04-03 13:48:24 +08:00 committed by GitHub
parent f94ee4fd3c
commit ba921fd903
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 136 additions and 106 deletions

View File

@ -4,6 +4,7 @@
package markdown package markdown
import ( import (
"html/template"
"strconv" "strconv"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
@ -29,9 +30,7 @@ func (n *Details) Kind() ast.NodeKind {
// NewDetails returns a new Paragraph node. // NewDetails returns a new Paragraph node.
func NewDetails() *Details { func NewDetails() *Details {
return &Details{ return &Details{}
BaseBlock: ast.BaseBlock{},
}
} }
// Summary is a block that contains the summary of details block // Summary is a block that contains the summary of details block
@ -54,9 +53,7 @@ func (n *Summary) Kind() ast.NodeKind {
// NewSummary returns a new Summary node. // NewSummary returns a new Summary node.
func NewSummary() *Summary { func NewSummary() *Summary {
return &Summary{ return &Summary{}
BaseBlock: ast.BaseBlock{},
}
} }
// TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox // TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox
@ -95,29 +92,6 @@ type Icon struct {
Name []byte Name []byte
} }
// Dump implements Node.Dump .
func (n *Icon) Dump(source []byte, level int) {
m := map[string]string{}
m["Name"] = string(n.Name)
ast.DumpHelper(n, source, level, m, nil)
}
// KindIcon is the NodeKind for Icon
var KindIcon = ast.NewNodeKind("Icon")
// Kind implements Node.Kind.
func (n *Icon) Kind() ast.NodeKind {
return KindIcon
}
// NewIcon returns a new Paragraph node.
func NewIcon(name string) *Icon {
return &Icon{
BaseInline: ast.BaseInline{},
Name: []byte(name),
}
}
// ColorPreview is an inline for a color preview // ColorPreview is an inline for a color preview
type ColorPreview struct { type ColorPreview struct {
ast.BaseInline ast.BaseInline
@ -175,3 +149,24 @@ func NewAttention(attentionType string) *Attention {
AttentionType: attentionType, AttentionType: attentionType,
} }
} }
var KindRawHTML = ast.NewNodeKind("RawHTML")
type RawHTML struct {
ast.BaseBlock
rawHTML template.HTML
}
func (n *RawHTML) Dump(source []byte, level int) {
m := map[string]string{}
m["RawHTML"] = string(n.rawHTML)
ast.DumpHelper(n, source, level, m, nil)
}
func (n *RawHTML) Kind() ast.NodeKind {
return KindRawHTML
}
func NewRawHTML(rawHTML template.HTML) *RawHTML {
return &RawHTML{rawHTML: rawHTML}
}

View File

@ -4,23 +4,22 @@
package markdown package markdown
import ( import (
"strings"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/svg"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast" east "github.com/yuin/goldmark/extension/ast"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func nodeToTable(meta *yaml.Node) ast.Node { func nodeToTable(meta *yaml.Node) ast.Node {
for { for meta != nil && meta.Kind == yaml.DocumentNode {
if meta == nil { meta = meta.Content[0]
return nil }
} if meta == nil {
switch meta.Kind { return nil
case yaml.DocumentNode:
meta = meta.Content[0]
continue
default:
}
break
} }
switch meta.Kind { switch meta.Kind {
case yaml.MappingNode: case yaml.MappingNode:
@ -72,12 +71,28 @@ func sequenceNodeToTable(meta *yaml.Node) ast.Node {
return table return table
} }
func nodeToDetails(meta *yaml.Node, icon string) ast.Node { func nodeToDetails(g *ASTTransformer, meta *yaml.Node) ast.Node {
for meta != nil && meta.Kind == yaml.DocumentNode {
meta = meta.Content[0]
}
if meta == nil {
return nil
}
if meta.Kind != yaml.MappingNode {
return nil
}
var keys []string
for i := 0; i < len(meta.Content); i += 2 {
if meta.Content[i].Kind == yaml.ScalarNode {
keys = append(keys, meta.Content[i].Value)
}
}
details := NewDetails() details := NewDetails()
details.SetAttributeString(g.renderInternal.SafeAttr("class"), g.renderInternal.SafeValue("frontmatter-content"))
summary := NewSummary() summary := NewSummary()
summary.AppendChild(summary, NewIcon(icon)) summaryInnerHTML := htmlutil.HTMLFormat("%s %s", svg.RenderHTML("octicon-table", 12), strings.Join(keys, ", "))
summary.AppendChild(summary, NewRawHTML(summaryInnerHTML))
details.AppendChild(details, summary) details.AppendChild(details, summary)
details.AppendChild(details, nodeToTable(meta)) details.AppendChild(details, nodeToTable(meta))
return details return details
} }

View File

@ -5,9 +5,6 @@ package markdown
import ( import (
"fmt" "fmt"
"regexp"
"strings"
"sync"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
@ -51,7 +48,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
tocList := make([]Header, 0, 20) tocList := make([]Header, 0, 20)
if rc.yamlNode != nil { if rc.yamlNode != nil {
metaNode := rc.toMetaNode() metaNode := rc.toMetaNode(g)
if metaNode != nil { if metaNode != nil {
node.InsertBefore(node, firstChild, metaNode) node.InsertBefore(node, firstChild, metaNode)
} }
@ -112,11 +109,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
} }
} }
// it is copied from old code, which is quite doubtful whether it is correct
var reValidIconName = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`^[-\w]+$`) // old: regexp.MustCompile("^[a-z ]+$")
})
// NewHTMLRenderer creates a HTMLRenderer to render in the gitea form. // NewHTMLRenderer creates a HTMLRenderer to render in the gitea form.
func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer { func NewHTMLRenderer(renderInternal *internal.RenderInternal, opts ...html.Option) renderer.NodeRenderer {
r := &HTMLRenderer{ r := &HTMLRenderer{
@ -141,11 +133,11 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindDocument, r.renderDocument) reg.Register(ast.KindDocument, r.renderDocument)
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(ast.KindCodeSpan, r.renderCodeSpan) reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(KindAttention, r.renderAttention) reg.Register(KindAttention, r.renderAttention)
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem) reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox) reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
reg.Register(KindRawHTML, r.renderRawHTML)
} }
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) {
@ -207,30 +199,14 @@ func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.N
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { func (r *HTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering { if !entering {
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
n := node.(*RawHTML)
n := node.(*Icon) _, err := w.WriteString(string(r.renderInternal.ProtectSafeAttrs(n.rawHTML)))
name := strings.TrimSpace(strings.ToLower(string(n.Name)))
if len(name) == 0 {
// skip this
return ast.WalkContinue, nil
}
if !reValidIconName().MatchString(name) {
// skip this
return ast.WalkContinue, nil
}
// FIXME: the "icon xxx" is from Fomantic UI, it's really questionable whether it still works correctly
err := r.renderInternal.FormatWithSafeAttrs(w, `<i class="icon %s"></i>`, name)
if err != nil { if err != nil {
return ast.WalkStop, err return ast.WalkStop, err
} }
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }

View File

@ -184,11 +184,7 @@ func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
// Preserve original length. // Preserve original length.
bufWithMetadataLength := len(buf) bufWithMetadataLength := len(buf)
rc := &RenderConfig{ rc := &RenderConfig{Meta: markup.RenderMetaAsDetails}
Meta: markup.RenderMetaAsDetails,
Icon: "table",
Lang: "",
}
buf, _ = ExtractMetadataBytes(buf, rc) buf, _ = ExtractMetadataBytes(buf, rc)
metaLength := bufWithMetadataLength - len(buf) metaLength := bufWithMetadataLength - len(buf)

View File

@ -383,18 +383,74 @@ func TestColorPreview(t *testing.T) {
} }
} }
func TestTaskList(t *testing.T) { func TestMarkdownFrontmatter(t *testing.T) {
testcases := []struct { testcases := []struct {
testcase string name string
input string
expected string expected string
}{ }{
{
"MapInFrontmatter",
`---
key1: val1
key2: val2
---
test
`,
`<details class="frontmatter-content"><summary><span>octicon-table(12/)</span> key1, key2</summary><table>
<thead>
<tr>
<th>key1</th>
<th>key2</th>
</tr>
</thead>
<tbody>
<tr>
<td>val1</td>
<td>val2</td>
</tr>
</tbody>
</table>
</details><p>test</p>
`,
},
{
"ListInFrontmatter",
`---
- item1
- item2
---
test
`,
`- item1
- item2
<p>test</p>
`,
},
{
"StringInFrontmatter",
`---
anything
---
test
`,
`anything
<p>test</p>
`,
},
{ {
// data-source-position should take into account YAML frontmatter. // data-source-position should take into account YAML frontmatter.
"ListAfterFrontmatter",
`--- `---
foo: bar foo: bar
--- ---
- [ ] task 1`, - [ ] task 1`,
`<details><summary><i class="icon table"></i></summary><table> `<details class="frontmatter-content"><summary><span>octicon-table(12/)</span> foo</summary><table>
<thead> <thead>
<tr> <tr>
<th>foo</th> <th>foo</th>
@ -414,9 +470,9 @@ foo: bar
} }
for _, test := range testcases { for _, test := range testcases {
res, err := markdown.RenderString(markup.NewTestRenderContext(), test.testcase) res, err := markdown.RenderString(markup.NewTestRenderContext(), test.input)
assert.NoError(t, err, "Unexpected error in testcase: %q", test.testcase) assert.NoError(t, err, "Unexpected error in testcase: %q", test.name)
assert.Equal(t, template.HTML(test.expected), res, "Unexpected result in testcase %q", test.testcase) assert.Equal(t, test.expected, string(res), "Unexpected result in testcase %q", test.name)
} }
} }

View File

@ -16,7 +16,6 @@ import (
// RenderConfig represents rendering configuration for this file // RenderConfig represents rendering configuration for this file
type RenderConfig struct { type RenderConfig struct {
Meta markup.RenderMetaMode Meta markup.RenderMetaMode
Icon string
TOC string // "false": hide, "side"/empty: in sidebar, "main"/"true": in main view TOC string // "false": hide, "side"/empty: in sidebar, "main"/"true": in main view
Lang string Lang string
yamlNode *yaml.Node yamlNode *yaml.Node
@ -74,7 +73,7 @@ func (rc *RenderConfig) UnmarshalYAML(value *yaml.Node) error {
type yamlRenderConfig struct { type yamlRenderConfig struct {
Meta *string `yaml:"meta"` Meta *string `yaml:"meta"`
Icon *string `yaml:"details_icon"` Icon *string `yaml:"details_icon"` // deprecated, because there is no font icon, so no custom icon
TOC *string `yaml:"include_toc"` TOC *string `yaml:"include_toc"`
Lang *string `yaml:"lang"` Lang *string `yaml:"lang"`
} }
@ -96,10 +95,6 @@ func (rc *RenderConfig) UnmarshalYAML(value *yaml.Node) error {
rc.Meta = renderMetaModeFromString(*cfg.Gitea.Meta) rc.Meta = renderMetaModeFromString(*cfg.Gitea.Meta)
} }
if cfg.Gitea.Icon != nil {
rc.Icon = strings.TrimSpace(strings.ToLower(*cfg.Gitea.Icon))
}
if cfg.Gitea.Lang != nil && *cfg.Gitea.Lang != "" { if cfg.Gitea.Lang != nil && *cfg.Gitea.Lang != "" {
rc.Lang = *cfg.Gitea.Lang rc.Lang = *cfg.Gitea.Lang
} }
@ -111,7 +106,7 @@ func (rc *RenderConfig) UnmarshalYAML(value *yaml.Node) error {
return nil return nil
} }
func (rc *RenderConfig) toMetaNode() ast.Node { func (rc *RenderConfig) toMetaNode(g *ASTTransformer) ast.Node {
if rc.yamlNode == nil { if rc.yamlNode == nil {
return nil return nil
} }
@ -119,7 +114,7 @@ func (rc *RenderConfig) toMetaNode() ast.Node {
case markup.RenderMetaAsTable: case markup.RenderMetaAsTable:
return nodeToTable(rc.yamlNode) return nodeToTable(rc.yamlNode)
case markup.RenderMetaAsDetails: case markup.RenderMetaAsDetails:
return nodeToDetails(rc.yamlNode, rc.Icon) return nodeToDetails(g, rc.yamlNode)
default: default:
return nil return nil
} }

View File

@ -21,42 +21,36 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
{ {
"empty", &RenderConfig{ "empty", &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
Lang: "", Lang: "",
}, "", }, "",
}, },
{ {
"lang", &RenderConfig{ "lang", &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
Lang: "test", Lang: "test",
}, "lang: test", }, "lang: test",
}, },
{ {
"metatable", &RenderConfig{ "metatable", &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
Lang: "", Lang: "",
}, "gitea: table", }, "gitea: table",
}, },
{ {
"metanone", &RenderConfig{ "metanone", &RenderConfig{
Meta: "none", Meta: "none",
Icon: "table",
Lang: "", Lang: "",
}, "gitea: none", }, "gitea: none",
}, },
{ {
"metadetails", &RenderConfig{ "metadetails", &RenderConfig{
Meta: "details", Meta: "details",
Icon: "table",
Lang: "", Lang: "",
}, "gitea: details", }, "gitea: details",
}, },
{ {
"metawrong", &RenderConfig{ "metawrong", &RenderConfig{
Meta: "details", Meta: "details",
Icon: "table",
Lang: "", Lang: "",
}, "gitea: wrong", }, "gitea: wrong",
}, },
@ -64,7 +58,6 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
"toc", &RenderConfig{ "toc", &RenderConfig{
TOC: "true", TOC: "true",
Meta: "table", Meta: "table",
Icon: "table",
Lang: "", Lang: "",
}, "include_toc: true", }, "include_toc: true",
}, },
@ -72,14 +65,12 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
"tocfalse", &RenderConfig{ "tocfalse", &RenderConfig{
TOC: "false", TOC: "false",
Meta: "table", Meta: "table",
Icon: "table",
Lang: "", Lang: "",
}, "include_toc: false", }, "include_toc: false",
}, },
{ {
"toclang", &RenderConfig{ "toclang", &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
TOC: "true", TOC: "true",
Lang: "testlang", Lang: "testlang",
}, ` }, `
@ -90,7 +81,6 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
{ {
"complexlang", &RenderConfig{ "complexlang", &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
Lang: "testlang", Lang: "testlang",
}, ` }, `
gitea: gitea:
@ -100,7 +90,6 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
{ {
"complexlang2", &RenderConfig{ "complexlang2", &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
Lang: "testlang", Lang: "testlang",
}, ` }, `
lang: notright lang: notright
@ -111,7 +100,6 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
{ {
"complexlang", &RenderConfig{ "complexlang", &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
Lang: "testlang", Lang: "testlang",
}, ` }, `
gitea: gitea:
@ -123,7 +111,6 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
Lang: "two", Lang: "two",
Meta: "table", Meta: "table",
TOC: "true", TOC: "true",
Icon: "smiley",
}, ` }, `
lang: one lang: one
include_toc: true include_toc: true
@ -139,14 +126,12 @@ func TestRenderConfig_UnmarshalYAML(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := &RenderConfig{ got := &RenderConfig{
Meta: "table", Meta: "table",
Icon: "table",
Lang: "", Lang: "",
} }
err := yaml.Unmarshal([]byte(strings.ReplaceAll(tt.args, "\t", " ")), got) err := yaml.Unmarshal([]byte(strings.ReplaceAll(tt.args, "\t", " ")), got)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tt.expected.Meta, got.Meta) assert.Equal(t, tt.expected.Meta, got.Meta)
assert.Equal(t, tt.expected.Icon, got.Icon)
assert.Equal(t, tt.expected.Lang, got.Lang) assert.Equal(t, tt.expected.Lang, got.Lang)
assert.Equal(t, tt.expected.TOC, got.TOC) assert.Equal(t, tt.expected.TOC, got.TOC)
}) })

View File

@ -511,6 +511,18 @@
padding-left: 2em; padding-left: 2em;
} }
.markup details.frontmatter-content summary {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin-bottom: 0.25em;
}
.markup details.frontmatter-content svg {
vertical-align: middle;
margin: 0 0.25em;
}
.file-revisions-btn { .file-revisions-btn {
display: block; display: block;
float: left; float: left;