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)))
}
}
+19 -2
View File
@@ -21,6 +21,8 @@ import (
"gitea.dev/modules/charset"
"gitea.dev/modules/git"
"gitea.dev/modules/indexer/code"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
@@ -35,8 +37,8 @@ func List(ctx *context.Context) {
}
var subNames []string
for _, tmplName := range templateNames {
subName := strings.TrimSuffix(tmplName, ".tmpl")
if !strings.HasPrefix(subName, "devtest-") {
subName, isTmpl := strings.CutSuffix(tmplName, ".tmpl")
if isTmpl && !strings.HasPrefix(subName, "devtest-") {
subNames = append(subNames, subName)
}
}
@@ -213,6 +215,19 @@ func prepareMockDataAvatarStack(ctx *context.Context) {
*/
}
func prepareMockDataMarkdown(ctx *context.Context) {
content, err := templates.AssetFS().ReadFile("devtest/markdown.md")
if err != nil {
ctx.ServerError("ReadFile", err)
return
}
rctx := markup.NewRenderContext(ctx).WithMetas(markup.ComposeSimpleDocumentMetas()).WithEnableHeadingIDGeneration(true)
ctx.Data["MarkdownHTML"], err = markdown.RenderString(rctx, string(content))
if err != nil {
ctx.ServerError("RenderString", err)
}
}
func prepareMockDataRelativeTime(ctx *context.Context) {
now := time.Now()
ctx.Data["TimeNow"] = now
@@ -244,6 +259,8 @@ func prepareMockData(ctx *context.Context) {
prepareMockDataBadgeCommitSign(ctx)
case "/devtest/badge-actions-svg":
prepareMockDataBadgeActionsSvg(ctx)
case "/devtest/markdown":
prepareMockDataMarkdown(ctx)
case "/devtest/relative-time":
prepareMockDataRelativeTime(ctx)
case "/devtest/toast-and-message":
+237
View File
@@ -0,0 +1,237 @@
# Markdown
## Text
### Inline elements
Text with **bold**, *italic*, ~~strikethrough~~, `inline code`, [a link](https://gitea.com), an autolink https://gitea.com and <kbd>Ctrl</kbd>+<kbd>C</kbd>.
H<sub>2</sub>O, x<sup>2</sup>, <mark>marked</mark>, <ins>inserted</ins>, <abbr title="HyperText Markup Language">HTML</abbr>, <samp>sample output</samp>, <var>variable</var>, <small>small</small>, <q>quote</q> and emoji :+1: :tada:.
### Paragraphs
A paragraph with a soft line break
continues on the same line, while a hard break\
starts a new line.
A second paragraph that is long enough to wrap onto multiple lines, so that the line height of paragraphs can be compared with the line height of list items further below.
### Headings
# Heading 1 with `code`
## Heading 2 with `code`
### Heading 3 with `code`
#### Heading 4 with `code`
##### Heading 5 with `code`
###### Heading 6 with `code`
### Horizontal rule
Text above the rule.
---
Text below the rule.
## Quotes
### Blockquote
> A blockquote with a paragraph.
>
> > A nested blockquote.
>
> - A list in a blockquote
> - Another item
>
> ```
> code in a blockquote
> ```
### Alerts
> [!NOTE]
> Useful information that users should know, even when skimming content.
> [!TIP]
> Helpful advice for doing things better or more easily.
> [!IMPORTANT]
> Key information users need to know to achieve their goal.
> [!WARNING]
> Urgent info that needs immediate user attention to avoid problems.
> [!CAUTION]
> Advises about risks or negative outcomes of certain actions.
>
> A second paragraph in an alert.
## Lists
### Tight list
- Item one
- Item two
- Nested item
- Deeper nested item
- Item three
### Loose list
- Item one
- Item two with a second paragraph
The second paragraph.
- Item three
### Ordered list
1. Item one
2. Item two
1. Nested item
1. Deeper nested item
3. Item three
### Ordered list with type
<ol>
<li>Item one
<ol type="a">
<li>Nested item with <code>type="a"</code></li>
<li>Another nested item</li>
</ol>
</li>
</ol>
### Ordered list with start
5. Item five
6. Item six
### Code block in list
1. A tight item with a code block:
```js
const a = 1;
```
2. Item after the code block
### Task list
- [ ] Unchecked task
- [x] Checked task
- [ ] Nested task
- Normal item
1. [ ] Ordered task
2. [x] Ordered checked task
### Loose task list
- [ ] Loose task
- [x] Loose checked task
A second paragraph in a loose task.
### Right-to-left task list
- [ ] משימה
- [x] משימה שהושלמה
## Code
### Code blocks
```go
package main
func main() {
println("hello")
}
```
```
A plain code block with a long line that needs to scroll horizontally: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```
An indented code block
### Math
Inline math $E = mc^2$ in a sentence.
$$
\sum_{i=1}^n x_i
$$
## Tables
### Table
| Left | Center | Right |
| :--- | :---: | ---: |
| `code` | 1 | 1.00 |
| text | 22 | 22.00 |
| longer cell content | 333 | 333.00 |
| <img src="/assets/img/logo.svg" alt="logo" width="24"> image | 4444 | 4444.00 |
### Table with paragraphs
<table>
<tr><th>Header</th><th>Header</th></tr>
<tr><td><p>Paragraph in a cell</p></td><td>Text in a cell</td></tr>
</table>
## Other
### Details
<details>
<summary>Closed details</summary>
Hidden content.
</details>
<details open>
<summary>Open details</summary>
Visible content.
</details>
<details>
<summary><h3>Heading in summary</h3></summary>
Hidden content.
</details>
### Definition list
<dl>
<dt>Term</dt>
<dd>Definition</dd>
<dt>First term</dt>
<dt>Second term</dt>
<dd>Shared definition</dd>
</dl>
### Images
An inline image ![logo](/assets/img/logo.svg) in text.
<p align="center"><img src="/assets/img/logo.svg" alt="centered logo" width="64"></p>
### Footnotes
A sentence with a footnote[^1] and another one[^2][^3].
[^1]: The first footnote.
[^2]: The second footnote with **bold** text.
[^3]: The third footnote.
+9
View File
@@ -0,0 +1,9 @@
{{template "devtest/devtest-header"}}
<div class="page-content devtest ui container">
<div class="ui segment tw-p-0">
<div class="file-view markup markdown">
{{.MarkdownHTML}}
</div>
</div>
</div>
{{template "devtest/devtest-footer"}}
+5 -47
View File
@@ -108,6 +108,11 @@ samp,
font-size: 0.95em; /* compensate for monospace fonts being usually slightly larger */
}
mark {
background: var(--color-highlight-bg);
color: inherit;
}
@font-face {
font-family: gitea-math-book; /* the Book weight shares its family name with the thinner Regular */
src: local("NewComputerModernMath-Book"), local("NewCMMath-Book");
@@ -456,53 +461,6 @@ a.label,
color: var(--color-text) !important;
}
.color-preview {
display: inline-block;
margin-left: 0.4em;
height: 0.67em;
width: 0.67em;
border-radius: var(--border-radius);
}
.attention-icon {
margin: 2px 6px 0 0;
}
blockquote.attention-note {
border-left-color: var(--color-info-text);
}
strong.attention-note, svg.attention-note {
color: var(--color-info-text);
}
blockquote.attention-tip {
border-left-color: var(--color-success-text);
}
strong.attention-tip, svg.attention-tip {
color: var(--color-success-text);
}
blockquote.attention-important {
border-left-color: var(--color-priority-text);
}
strong.attention-important, svg.attention-important {
color: var(--color-priority-text);
}
blockquote.attention-warning {
border-left-color: var(--color-warning-text);
}
strong.attention-warning, svg.attention-warning {
color: var(--color-warning-text);
}
blockquote.attention-caution {
border-left-color: var(--color-error-text);
}
strong.attention-caution, svg.attention-caution {
color: var(--color-error-text);
}
/* FIXME: this is a longstanding dirty patch since 2015, it only makes the pages more messy and shouldn't be used */
.center {
text-align: center;
+1
View File
@@ -49,6 +49,7 @@
@import "./features/console.css";
@import "./features/captcha.css";
@import "./markup/attention.css";
@import "./markup/content.css";
@import "./markup/codeblock.css";
@import "./markup/codepreview.css";
+43
View File
@@ -0,0 +1,43 @@
.attention-header > p:first-child {
display: flex;
align-items: center;
}
.attention-icon {
margin-inline-end: 6px;
}
blockquote.attention-note {
border-left-color: var(--color-info-text);
}
strong.attention-note, svg.attention-note {
color: var(--color-info-text);
}
blockquote.attention-tip {
border-left-color: var(--color-success-text);
}
strong.attention-tip, svg.attention-tip {
color: var(--color-success-text);
}
blockquote.attention-important {
border-left-color: var(--color-priority-text);
}
strong.attention-important, svg.attention-important {
color: var(--color-priority-text);
}
blockquote.attention-warning {
border-left-color: var(--color-warning-text);
}
strong.attention-warning, svg.attention-warning {
color: var(--color-warning-text);
}
blockquote.attention-caution {
border-left-color: var(--color-error-text);
}
strong.attention-caution, svg.attention-caution {
color: var(--color-error-text);
}
+47 -26
View File
@@ -86,6 +86,7 @@
.markup h5 code,
.markup h6 tt,
.markup h6 code {
padding: 0 0.2em;
font-size: inherit;
}
@@ -118,6 +119,16 @@
color: var(--color-text-light-2);
}
.markup summary :is(h1, h2, h3, h4, h5, h6) {
display: inline-block;
padding-bottom: 0;
border-bottom: 0;
}
.markup summary .anchor {
margin-inline-start: -40px; /* clear the disclosure marker */
}
.markup p,
.markup blockquote,
.markup details,
@@ -138,8 +149,12 @@ In markup content, we always use bottom margin for all elements */
margin-bottom: 16px;
}
.markup p {
line-height: inherit; /* override base.css */
}
.markup hr {
height: 4px;
height: 1px;
padding: 0;
margin: 16px 0;
background-color: var(--color-secondary);
@@ -159,10 +174,6 @@ In markup content, we always use bottom margin for all elements */
margin-top: 4px;
}
.markup .task-list-item p + ul {
margin-top: 16px;
}
.markup .task-list-item input[type="checkbox"] {
margin-bottom: 0.25em;
margin-inline: -1.4em 0.6em;
@@ -170,13 +181,10 @@ In markup content, we always use bottom margin for all elements */
padding: 0;
}
.markup .task-list-item input[type="checkbox"] + p {
margin-inline-start: -0.2em;
display: inline;
}
.markup .task-list-item > p {
margin-inline: 16px;
/* a paragraph with another direction than its list has no list padding to hang the checkbox into */
.markup :is(ul, ol):dir(ltr) > .task-list-item > p:dir(rtl) > input[type="checkbox"]:first-child,
.markup :is(ul, ol):dir(rtl) > .task-list-item > p:dir(ltr) > input[type="checkbox"]:first-child {
margin-inline-start: 0;
}
.markup .task-list-item + .task-list-item {
@@ -200,11 +208,14 @@ In markup content, we always use bottom margin for all elements */
margin-bottom: 0;
}
.markup ol ol,
.markup ul ol {
.markup :is(ol, ul) ol:not([type]) {
list-style-type: lower-roman;
}
.markup :is(ol, ul) :is(ol, ul) ol:not([type]) {
list-style-type: lower-alpha;
}
.markup li > p {
margin-top: 16px;
}
@@ -225,13 +236,18 @@ In markup content, we always use bottom margin for all elements */
font-weight: var(--font-weight-semibold);
}
.markup dl dt + dt {
margin-top: 0;
}
.markup dl dd {
padding: 0 16px;
margin-inline-start: 0;
margin-bottom: 16px;
}
.markup blockquote {
margin-inline-start: 0;
margin-inline: 0;
padding: 0 15px;
color: var(--color-text-light-2);
border-left: 0.25em solid var(--color-secondary);
@@ -250,6 +266,11 @@ In markup content, we always use bottom margin for all elements */
width: 100%;
max-width: 100%;
overflow: auto;
font-variant: tabular-nums;
}
.markup table td > :last-child {
margin-bottom: 0;
}
.markup table th {
@@ -297,17 +318,8 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
padding: 0; /* let the file-not-rendered-prompt layout itself */
}
/* this background ensures images can break <hr>. We can only do this on
cases where the background is known and not transparent. */
.file-view.markup img,
.file-view.markup video,
.comment-body .markup img, /* regular comment */
.comment-body .markup video,
.comment-content .markup img, /* code comment */
.comment-content .markup video,
.wiki .markup img,
.wiki .markup video {
background: var(--color-box-body);
.markup :is(h1, h2, hr) {
display: flow-root; /* keep borders clear of floated images */
}
.markup img[align="right"],
@@ -401,6 +413,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
background-color: var(--color-markup-code-inline);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
max-width: 100%;
}
.markup details.frontmatter-content summary {
@@ -511,3 +524,11 @@ the manually written code-block elements on "packages" pages don't have the cont
.markup .auto-hide-control .ui.button:active {
background: var(--color-secondary-dark-1) !important;
}
.markup .color-preview {
display: inline-block;
margin-left: 0.4em;
height: 0.67em;
width: 0.67em;
border-radius: var(--border-radius);
}