enhance(markup): align markdown rendering details with GitHub (#39955)

Fixes markdown rendering details where Gitea deviated from GitHub
unintentionally, and adds a `/devtest/markdown` page to review markdown
rendering.

- Paragraphs get the intended 1.5 line height
- Blockquotes, alerts and definition lists no longer inherit browser
default margins
- Loose task lists keep their spacing, the checkbox renders inside the
paragraph like on GitHub
- Footnote references render as `[1]` so adjacent ones stay apart
without CSS
- Code in headings, headings in summaries, nested ordered lists, tables,
`<mark>`, alert icons and `<hr>` follow GitHub

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-29 11:18:04 +00:00
1 parent 7308c5fb97
commit eb9267365d
13 files changed
+401 -169

No files matched your search

+3 -1
View File
@@ -401,8 +401,10 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt
_, _ = w.WriteString(`"><a href="#fn:user-content-`)
_, _ = w.Write(n.Name)
_, _ = w.WriteString(`" class="footnote-ref" role="doc-noteref">`) // FIXME: here and below, need to keep the classes
// literal brackets separate adjacent refs, also where no CSS applies like in mails
_, _ = w.WriteString("[")
_, _ = w.WriteString(is)
_, _ = w.WriteString(` </a></sup>`) // the style doesn't work at the moment, so add a space to separate the names
_, _ = w.WriteString("]</a></sup>")
}
return ast.WalkContinue, nil
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestRender_IssueList(t *testing.T) {
</ul>`},
{
"IssueFootnote", "foo[^1][^2]\n\n[^1]: bar\n[^2]: baz",
`<p>foo<sup id="fnref:user-content-1-12345"><a href="#fn:user-content-1-12345" rel="nofollow">1 </a></sup><sup id="fnref:user-content-2-12345"><a href="#fn:user-content-2-12345" rel="nofollow">2 </a></sup></p>
`<p>foo<sup id="fnref:user-content-1-12345"><a href="#fn:user-content-1-12345" rel="nofollow">[1]</a></sup><sup id="fnref:user-content-2-12345"><a href="#fn:user-content-2-12345" rel="nofollow">[2]</a></sup></p>
<div>
<hr/>
<ol>
-31
View File
@@ -5,7 +5,6 @@ package markdown
import (
"html/template"
"strconv"
"github.com/yuin/goldmark/ast"
)
@@ -56,36 +55,6 @@ func NewSummary() *Summary {
return &Summary{}
}
// TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox
type TaskCheckBoxListItem struct {
*ast.ListItem
IsChecked bool
SourcePosition int
}
// KindTaskCheckBoxListItem is the NodeKind for TaskCheckBoxListItem
var KindTaskCheckBoxListItem = ast.NewNodeKind("TaskCheckBoxListItem")
// Dump implements Node.Dump .
func (n *TaskCheckBoxListItem) Dump(source []byte, level int) {
m := map[string]string{}
m["IsChecked"] = strconv.FormatBool(n.IsChecked)
m["SourcePosition"] = strconv.FormatInt(int64(n.SourcePosition), 10)
ast.DumpHelper(n, source, level, m, nil)
}
// Kind implements Node.Kind.
func (n *TaskCheckBoxListItem) Kind() ast.NodeKind {
return KindTaskCheckBoxListItem
}
// NewTaskCheckBoxListItem returns a new TaskCheckBoxListItem node.
func NewTaskCheckBoxListItem(listItem *ast.ListItem) *TaskCheckBoxListItem {
return &TaskCheckBoxListItem{
ListItem: listItem,
}
}
// Icon is an inline for a Fomantic UI icon
type Icon struct {
ast.BaseInline
-1
View File
@@ -146,7 +146,6 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(KindAttention, r.renderAttention)
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
reg.Register(KindRawHTML, r.renderRawHTML)
}
+11 -1
View File
@@ -125,6 +125,10 @@ Here are some links to the most important topics. You can find the full list of
- [x] checked
- [ ] still unchecked
* [x] loose
second paragraph
### Definition list
First Term
@@ -209,6 +213,12 @@ This PR has been generated by [Renovate Bot](https://github.com/renovatebot/reno
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="450" checked=""/>checked</li>
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="464"/>still unchecked</li>
</ul>
<ul>
<li class="task-list-item">
<p><input type="checkbox" disabled="" data-source-position="487" checked=""/>loose</p>
<p>second paragraph</p>
</li>
</ul>
<h3 id="user-content-definition-list">Definition list</h3>
<dl>
<dt>First Term</dt>
@@ -218,7 +228,7 @@ This PR has been generated by [Renovate Bot](https://github.com/renovatebot/reno
<dd>This is another definition of the second term.</dd>
</dl>
<h3 id="user-content-footnotes">Footnotes</h3>
<p>Here is a simple footnote,<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">1 </a></sup> and here is a longer one.<sup id="fnref:user-content-bignote"><a href="#fn:user-content-bignote" rel="nofollow">2 </a></sup></p>
<p>Here is a simple footnote,<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">[1]</a></sup> and here is a longer one.<sup id="fnref:user-content-bignote"><a href="#fn:user-content-bignote" rel="nofollow">[2]</a></sup></p>
<div>
<hr/>
<ol>
+25 -59
View File
@@ -4,7 +4,7 @@
package markdown
import (
"fmt"
"strconv"
"gitea.dev/modules/markup"
@@ -14,71 +14,37 @@ import (
"github.com/yuin/goldmark/util"
)
func (r *HTMLRenderer) renderTaskCheckBoxListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*TaskCheckBoxListItem) //nolint:forcetypeassert // registered for KindTaskCheckBoxListItem only
if entering {
if n.Attributes() != nil {
_, _ = w.WriteString("<li")
html.RenderAttributes(w, n, html.ListItemAttributeFilter)
_ = w.WriteByte('>')
} else {
_, _ = w.WriteString("<li>")
}
fmt.Fprintf(w, `<input type="checkbox" disabled="" data-source-position="%d"`, n.SourcePosition)
if n.IsChecked {
_, _ = w.WriteString(` checked=""`)
}
if r.XHTML {
_, _ = w.WriteString(` />`)
} else {
_ = w.WriteByte('>')
}
fc := n.FirstChild()
if fc != nil {
if _, ok := fc.(*ast.TextBlock); !ok {
_ = w.WriteByte('\n')
}
}
func (r *HTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
checkBox, ok := node.(*east.TaskCheckBox)
if !entering || !ok {
return ast.WalkContinue, nil
}
_, _ = w.WriteString(`<input type="checkbox" disabled=""`)
html.RenderAttributes(w, checkBox, nil)
if checkBox.IsChecked {
_, _ = w.WriteString(` checked=""`)
}
if r.XHTML {
_, _ = w.WriteString(` />`)
} else {
_, _ = w.WriteString("</li>\n")
_ = w.WriteByte('>')
}
return ast.WalkContinue, nil
}
func (r *HTMLRenderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkContinue, nil
}
func (g *ASTTransformer) transformList(_ *markup.RenderContext, v *ast.List, rc *RenderConfig) {
if v.HasChildren() {
children := make([]ast.Node, 0, v.ChildCount())
child := v.FirstChild()
for child != nil {
children = append(children, child)
child = child.NextSibling()
for listItem := v.FirstChild(); listItem != nil; listItem = listItem.NextSibling() {
block := listItem.FirstChild()
if block == nil {
continue
}
v.RemoveChildren(v)
for _, child := range children {
listItem := child.(*ast.ListItem) //nolint:forcetypeassert // a list only holds list items
if !child.HasChildren() || !child.FirstChild().HasChildren() {
v.AppendChild(v, child)
continue
}
taskCheckBox, ok := child.FirstChild().FirstChild().(*east.TaskCheckBox)
if !ok {
v.AppendChild(v, child)
continue
}
newChild := NewTaskCheckBoxListItem(listItem)
newChild.IsChecked = taskCheckBox.IsChecked
newChild.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("task-list-item")))
segments := newChild.FirstChild().Lines()
if segments.Len() > 0 {
segment := segments.At(0)
newChild.SourcePosition = rc.metaLength + segment.Start
}
v.AppendChild(v, newChild)
checkBox, ok := block.FirstChild().(*east.TaskCheckBox)
if !ok {
continue
}
listItem.SetAttributeString(g.renderInternal.SafeAttr("class"), []byte(g.renderInternal.SafeValue("task-list-item")))
if lines := block.Lines(); lines.Len() > 0 {
checkBox.SetAttributeString("data-source-position", []byte(strconv.Itoa(rc.metaLength+lines.At(0).Start)))
}
}