diff options
Diffstat (limited to 'modules/markup/markdown')
29 files changed, 3962 insertions, 0 deletions
diff --git a/modules/markup/markdown/ast.go b/modules/markup/markdown/ast.go new file mode 100644 index 0000000..7f0ac6a --- /dev/null +++ b/modules/markup/markdown/ast.go @@ -0,0 +1,176 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "strconv" + + "github.com/yuin/goldmark/ast" +) + +// Details is a block that contains Summary and details +type Details struct { + ast.BaseBlock +} + +// Dump implements Node.Dump . +func (n *Details) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// KindDetails is the NodeKind for Details +var KindDetails = ast.NewNodeKind("Details") + +// Kind implements Node.Kind. +func (n *Details) Kind() ast.NodeKind { + return KindDetails +} + +// NewDetails returns a new Paragraph node. +func NewDetails() *Details { + return &Details{ + BaseBlock: ast.BaseBlock{}, + } +} + +// IsDetails returns true if the given node implements the Details interface, +// otherwise false. +func IsDetails(node ast.Node) bool { + _, ok := node.(*Details) + return ok +} + +// Summary is a block that contains the summary of details block +type Summary struct { + ast.BaseBlock +} + +// Dump implements Node.Dump . +func (n *Summary) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// KindSummary is the NodeKind for Summary +var KindSummary = ast.NewNodeKind("Summary") + +// Kind implements Node.Kind. +func (n *Summary) Kind() ast.NodeKind { + return KindSummary +} + +// NewSummary returns a new Summary node. +func NewSummary() *Summary { + return &Summary{ + BaseBlock: ast.BaseBlock{}, + } +} + +// IsSummary returns true if the given node implements the Summary interface, +// otherwise false. +func IsSummary(node ast.Node) bool { + _, ok := node.(*Summary) + return ok +} + +// 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, + } +} + +// IsTaskCheckBoxListItem returns true if the given node implements the TaskCheckBoxListItem interface, +// otherwise false. +func IsTaskCheckBoxListItem(node ast.Node) bool { + _, ok := node.(*TaskCheckBoxListItem) + return ok +} + +// Icon is an inline for a fomantic icon +type Icon struct { + ast.BaseInline + 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), + } +} + +// IsIcon returns true if the given node implements the Icon interface, +// otherwise false. +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, + } +} diff --git a/modules/markup/markdown/callout/ast.go b/modules/markup/markdown/callout/ast.go new file mode 100644 index 0000000..a5b1bbc --- /dev/null +++ b/modules/markup/markdown/callout/ast.go @@ -0,0 +1,37 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package callout + +import ( + "github.com/yuin/goldmark/ast" +) + +// Attention is an inline for an attention +type Attention struct { + ast.BaseInline + AttentionType string +} + +// Dump implements Node.Dump. +func (n *Attention) Dump(source []byte, level int) { + m := map[string]string{} + m["AttentionType"] = n.AttentionType + ast.DumpHelper(n, source, level, m, nil) +} + +// KindAttention is the NodeKind for Attention +var KindAttention = ast.NewNodeKind("Attention") + +// Kind implements Node.Kind. +func (n *Attention) Kind() ast.NodeKind { + return KindAttention +} + +// NewAttention returns a new Attention node. +func NewAttention(attentionType string) *Attention { + return &Attention{ + BaseInline: ast.BaseInline{}, + AttentionType: attentionType, + } +} diff --git a/modules/markup/markdown/callout/github.go b/modules/markup/markdown/callout/github.go new file mode 100644 index 0000000..9b8b611 --- /dev/null +++ b/modules/markup/markdown/callout/github.go @@ -0,0 +1,141 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package callout + +import ( + "strings" + + "code.gitea.io/gitea/modules/svg" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type GitHubCalloutTransformer struct{} + +// Transform transforms the given AST tree. +func (g *GitHubCalloutTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + supportedAttentionTypes := map[string]bool{ + "note": true, + "tip": true, + "important": true, + "warning": true, + "caution": true, + } + + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + if v, ok := n.(*ast.Blockquote); ok { + if v.ChildCount() == 0 { + return ast.WalkContinue, nil + } + + // We only want attention blockquotes when the AST looks like: + // Text: "[" + // Text: "!TYPE" + // Text(SoftLineBreak): "]" + + // grab these nodes and make sure we adhere to the attention blockquote structure + firstParagraph := v.FirstChild() + if firstParagraph.ChildCount() < 3 { + return ast.WalkContinue, nil + } + firstTextNode, ok := firstParagraph.FirstChild().(*ast.Text) + if !ok || string(firstTextNode.Text(reader.Source())) != "[" { + return ast.WalkContinue, nil + } + secondTextNode, ok := firstTextNode.NextSibling().(*ast.Text) + if !ok { + return ast.WalkContinue, nil + } + // If the second node's text isn't one of the supported attention + // types, continue walking. + secondTextNodeText := secondTextNode.Text(reader.Source()) + attentionType := strings.ToLower(strings.TrimPrefix(string(secondTextNodeText), "!")) + if _, has := supportedAttentionTypes[attentionType]; !has { + return ast.WalkContinue, nil + } + + thirdTextNode, ok := secondTextNode.NextSibling().(*ast.Text) + if !ok || string(thirdTextNode.Text(reader.Source())) != "]" { + return ast.WalkContinue, nil + } + + // color the blockquote + v.SetAttributeString("class", []byte("attention-header attention-"+attentionType)) + + // create an emphasis to make it bold + attentionParagraph := ast.NewParagraph() + attentionParagraph.SetAttributeString("class", []byte("attention-title")) + emphasis := ast.NewEmphasis(2) + emphasis.SetAttributeString("class", []byte("attention-"+attentionType)) + firstParagraph.InsertBefore(firstParagraph, firstTextNode, emphasis) + + // capitalize first letter + attentionText := ast.NewString([]byte(strings.ToUpper(string(attentionType[0])) + attentionType[1:])) + + // replace the ![TYPE] with a dedicated paragraph of icon+Type + emphasis.AppendChild(emphasis, attentionText) + attentionParagraph.AppendChild(attentionParagraph, NewAttention(attentionType)) + attentionParagraph.AppendChild(attentionParagraph, emphasis) + firstParagraph.Parent().InsertBefore(firstParagraph.Parent(), firstParagraph, attentionParagraph) + firstParagraph.RemoveChild(firstParagraph, firstTextNode) + firstParagraph.RemoveChild(firstParagraph, secondTextNode) + firstParagraph.RemoveChild(firstParagraph, thirdTextNode) + } + return ast.WalkContinue, nil + }) +} + +type GitHubCalloutHTMLRenderer struct { + html.Config +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *GitHubCalloutHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindAttention, r.renderAttention) +} + +// renderAttention renders a quote marked with i.e. "> **Note**" or "> **Warning**" with a corresponding svg +func (r *GitHubCalloutHTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + n := node.(*Attention) + + var octiconName string + switch n.AttentionType { + case "note": + octiconName = "info" + case "tip": + octiconName = "light-bulb" + case "important": + octiconName = "report" + case "warning": + octiconName = "alert" + case "caution": + octiconName = "stop" + default: + octiconName = "info" + } + _, _ = w.WriteString(string(svg.RenderHTML("octicon-"+octiconName, 16, "attention-icon attention-"+n.AttentionType))) + } + return ast.WalkContinue, nil +} + +func NewGitHubCalloutHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &GitHubCalloutHTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} diff --git a/modules/markup/markdown/callout/github_legacy.go b/modules/markup/markdown/callout/github_legacy.go new file mode 100644 index 0000000..96354b1 --- /dev/null +++ b/modules/markup/markdown/callout/github_legacy.go @@ -0,0 +1,78 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package callout + +import ( + "strings" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// Transformer for GitHub's legacy callout markup. +type GitHubLegacyCalloutTransformer struct{} + +func (g *GitHubLegacyCalloutTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + supportedCalloutTypes := map[string]bool{"Note": true, "Warning": true} + + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + if v, ok := n.(*ast.Blockquote); ok { + if v.ChildCount() == 0 { + return ast.WalkContinue, nil + } + + // The first paragraph contains the callout type. + firstParagraph := v.FirstChild() + if firstParagraph.ChildCount() < 1 { + return ast.WalkContinue, nil + } + + // In the legacy GitHub callout markup, the first node of the first + // paragraph should be an emphasis. + calloutNode, ok := firstParagraph.FirstChild().(*ast.Emphasis) + if !ok { + return ast.WalkContinue, nil + } + calloutText := string(calloutNode.Text(reader.Source())) + calloutType := strings.ToLower(calloutText) + // We only support "Note" and "Warning" callouts in legacy mode, + // match only those. + if _, has := supportedCalloutTypes[calloutText]; !has { + return ast.WalkContinue, nil + } + + // Set the attention attribute on the emphasis + calloutNode.SetAttributeString("class", []byte("attention-"+calloutType)) + + // color the blockquote + v.SetAttributeString("class", []byte("attention-header attention-"+calloutType)) + + // Create new paragraph. + attentionParagraph := ast.NewParagraph() + attentionParagraph.SetAttributeString("class", []byte("attention-title")) + + // Move the callout node to the paragraph and insert the paragraph. + attentionParagraph.AppendChild(attentionParagraph, NewAttention(calloutType)) + attentionParagraph.AppendChild(attentionParagraph, calloutNode) + firstParagraph.Parent().InsertBefore(firstParagraph.Parent(), firstParagraph, attentionParagraph) + firstParagraph.RemoveChild(firstParagraph, calloutNode) + + // Remove softbreak line if there's one. + if firstParagraph.ChildCount() >= 1 { + softBreakNode, ok := firstParagraph.FirstChild().(*ast.Text) + if ok && softBreakNode.SoftLineBreak() { + firstParagraph.RemoveChild(firstParagraph, softBreakNode) + } + } + } + + return ast.WalkContinue, nil + }) +} diff --git a/modules/markup/markdown/color_util.go b/modules/markup/markdown/color_util.go new file mode 100644 index 0000000..355fef3 --- /dev/null +++ b/modules/markup/markdown/color_util.go @@ -0,0 +1,19 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import "regexp" + +var ( + hexRGB = regexp.MustCompile(`^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$`) + hsl = regexp.MustCompile(`^hsl\([ ]*([012]?[0-9]{1,2}|3[0-5][0-9]|360),[ ]*([0-9]{0,2}|100)\%,[ ]*([0-9]{0,2}|100)\%\)$`) + hsla = regexp.MustCompile(`^hsla\(([ ]*[012]?[0-9]{1,2}|3[0-5][0-9]|360),[ ]*([0-9]{0,2}|100)\%,[ ]*([0-9]{0,2}|100)\%,[ ]*(1|1\.0|0|(0\.[0-9]+))\)$`) + rgb = regexp.MustCompile(`^rgb\(([ ]*((([0-9]{1,2}|100)\%)|(([01]?[0-9]{1,2})|(2[0-4][0-9])|(25[0-5]))),){2}([ ]*((([0-9]{1,2}|100)\%)|(([01]?[0-9]{1,2})|(2[0-4][0-9])|(25[0-5]))))\)$`) + rgba = regexp.MustCompile(`^rgba\(([ ]*((([0-9]{1,2}|100)\%)|(([01]?[0-9]{1,2})|(2[0-4][0-9])|(25[0-5]))),){3}[ ]*(1(\.0)?|0|(0\.[0-9]+))\)$`) +) + +// matchColor return if color is in the form of hex RGB, HSL(A) or RGB(A). +func matchColor(color string) bool { + return hexRGB.MatchString(color) || rgb.MatchString(color) || rgba.MatchString(color) || hsl.MatchString(color) || hsla.MatchString(color) +} diff --git a/modules/markup/markdown/color_util_test.go b/modules/markup/markdown/color_util_test.go new file mode 100644 index 0000000..c6e0555 --- /dev/null +++ b/modules/markup/markdown/color_util_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMatchColor(t *testing.T) { + testCases := []struct { + input string + expected bool + }{ + {"#ddeeffa0", true}, + {"#ddeefe", true}, + {"#abcdef", true}, + {"#abcdeg", false}, + {"#abcdefg0", false}, + {"black", false}, + {"violet", false}, + {"rgb(255, 255, 255)", true}, + {"rgb(0, 0, 0)", true}, + {"rgb(256, 0, 0)", false}, + {"rgb(0, 256, 0)", false}, + {"rgb(0, 0, 256)", false}, + {"rgb(0, 0, 0, 1)", false}, + {"rgba(0, 0, 0)", false}, + {"rgba(0, 255, 0, 1)", true}, + {"rgba(32, 255, 12, 0.55)", true}, + {"rgba(32, 256, 12, 0.55)", false}, + {"hsl(0, 0%, 0%)", true}, + {"hsl(360, 100%, 100%)", true}, + {"hsl(361, 100%, 50%)", false}, + {"hsl(360, 101%, 50%)", false}, + {"hsl(360, 100%, 101%)", false}, + {"hsl(0, 0%, 0%, 0)", false}, + {"hsla(0, 0%, 0%)", false}, + {"hsla(0, 0%, 0%, 0)", true}, + {"hsla(0, 0%, 0%, 1)", true}, + {"hsla(0, 0%, 0%, 0.5)", true}, + {"hsla(0, 0%, 0%, 1.5)", false}, + } + for _, testCase := range testCases { + actual := matchColor(testCase.input) + assert.Equal(t, testCase.expected, actual) + } +} diff --git a/modules/markup/markdown/convertyaml.go b/modules/markup/markdown/convertyaml.go new file mode 100644 index 0000000..1675b68 --- /dev/null +++ b/modules/markup/markdown/convertyaml.go @@ -0,0 +1,83 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "github.com/yuin/goldmark/ast" + east "github.com/yuin/goldmark/extension/ast" + "gopkg.in/yaml.v3" +) + +func nodeToTable(meta *yaml.Node) ast.Node { + for { + if meta == nil { + return nil + } + switch meta.Kind { + case yaml.DocumentNode: + meta = meta.Content[0] + continue + default: + } + break + } + switch meta.Kind { + case yaml.MappingNode: + return mappingNodeToTable(meta) + case yaml.SequenceNode: + return sequenceNodeToTable(meta) + default: + return ast.NewString([]byte(meta.Value)) + } +} + +func mappingNodeToTable(meta *yaml.Node) ast.Node { + table := east.NewTable() + alignments := make([]east.Alignment, 0, len(meta.Content)/2) + for i := 0; i < len(meta.Content); i += 2 { + alignments = append(alignments, east.AlignNone) + } + + headerRow := east.NewTableRow(alignments) + valueRow := east.NewTableRow(alignments) + for i := 0; i < len(meta.Content); i += 2 { + cell := east.NewTableCell() + + cell.AppendChild(cell, nodeToTable(meta.Content[i])) + headerRow.AppendChild(headerRow, cell) + + if i+1 < len(meta.Content) { + cell = east.NewTableCell() + cell.AppendChild(cell, nodeToTable(meta.Content[i+1])) + valueRow.AppendChild(valueRow, cell) + } + } + + table.AppendChild(table, east.NewTableHeader(headerRow)) + table.AppendChild(table, valueRow) + return table +} + +func sequenceNodeToTable(meta *yaml.Node) ast.Node { + table := east.NewTable() + alignments := []east.Alignment{east.AlignNone} + for _, item := range meta.Content { + row := east.NewTableRow(alignments) + cell := east.NewTableCell() + cell.AppendChild(cell, nodeToTable(item)) + row.AppendChild(row, cell) + table.AppendChild(table, row) + } + return table +} + +func nodeToDetails(meta *yaml.Node, icon string) ast.Node { + details := NewDetails() + summary := NewSummary() + summary.AppendChild(summary, NewIcon(icon)) + details.AppendChild(details, summary) + details.AppendChild(details, nodeToTable(meta)) + + return details +} diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go new file mode 100644 index 0000000..0290e13 --- /dev/null +++ b/modules/markup/markdown/goldmark.go @@ -0,0 +1,213 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "fmt" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + + "github.com/yuin/goldmark/ast" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +var byteMailto = []byte("mailto:") + +// ASTTransformer is a default transformer of the goldmark tree. +type ASTTransformer struct{} + +func (g *ASTTransformer) applyElementDir(n ast.Node) { + if markup.DefaultProcessorHelper.ElementDir != "" { + n.SetAttributeString("dir", []byte(markup.DefaultProcessorHelper.ElementDir)) + } +} + +// Transform transforms the given AST tree. +func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { + firstChild := node.FirstChild() + tocMode := "" + ctx := pc.Get(renderContextKey).(*markup.RenderContext) + rc := pc.Get(renderConfigKey).(*RenderConfig) + + tocList := make([]markup.Header, 0, 20) + if rc.yamlNode != nil { + metaNode := rc.toMetaNode() + if metaNode != nil { + node.InsertBefore(node, firstChild, metaNode) + } + tocMode = rc.TOC + } + + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch v := n.(type) { + case *ast.Heading: + g.transformHeading(ctx, v, reader, &tocList) + case *ast.Paragraph: + g.applyElementDir(v) + case *ast.Image: + g.transformImage(ctx, v) + case *ast.Link: + g.transformLink(ctx, v) + case *ast.List: + g.transformList(ctx, v, rc) + case *ast.Text: + if v.SoftLineBreak() && !v.HardLineBreak() { + if ctx.Metas["mode"] != "document" { + v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments) + } else { + v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments) + } + } + case *ast.CodeSpan: + g.transformCodeSpan(ctx, v, reader) + } + return ast.WalkContinue, nil + }) + + showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main" + showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar + if len(tocList) > 0 && (showTocInMain || showTocInSidebar) { + if showTocInMain { + tocNode := createTOCNode(tocList, rc.Lang, nil) + node.InsertBefore(node, firstChild, tocNode) + } else { + tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"}) + ctx.SidebarTocNode = tocNode + } + } + + if len(rc.Lang) > 0 { + node.SetAttributeString("lang", []byte(rc.Lang)) + } +} + +// NewHTMLRenderer creates a HTMLRenderer to render +// in the gitea form. +func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &HTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +// HTMLRenderer is a renderer.NodeRenderer implementation that +// renders gitea specific features. +type HTMLRenderer struct { + html.Config +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindDocument, r.renderDocument) + 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) +} + +func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.Document) + + if val, has := n.AttributeString("lang"); has { + var err error + if entering { + _, err = w.WriteString("<div") + if err == nil { + _, err = w.WriteString(fmt.Sprintf(` lang=%q`, val)) + } + if err == nil { + _, err = w.WriteRune('>') + } + } else { + _, err = w.WriteString("</div>") + } + + if err != nil { + return ast.WalkStop, err + } + } + + return ast.WalkContinue, nil +} + +func (r *HTMLRenderer) renderDetails(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + var err error + if entering { + if _, err = w.WriteString("<details"); err != nil { + return ast.WalkStop, err + } + html.RenderAttributes(w, node, nil) + _, err = w.WriteString(">") + } else { + _, err = w.WriteString("</details>") + } + + if err != nil { + return ast.WalkStop, err + } + + return ast.WalkContinue, nil +} + +func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + var err error + if entering { + _, err = w.WriteString("<summary>") + } else { + _, err = w.WriteString("</summary>") + } + + if err != nil { + return ast.WalkStop, err + } + + return ast.WalkContinue, nil +} + +var validNameRE = regexp.MustCompile("^[a-z ]+$") + +func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + n := node.(*Icon) + + name := strings.TrimSpace(strings.ToLower(string(n.Name))) + + if len(name) == 0 { + // skip this + return ast.WalkContinue, nil + } + + if !validNameRE.MatchString(name) { + // skip this + return ast.WalkContinue, nil + } + + var err error + _, err = w.WriteString(fmt.Sprintf(`<i class="icon %s"></i>`, name)) + if err != nil { + return ast.WalkStop, err + } + + return ast.WalkContinue, nil +} diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go new file mode 100644 index 0000000..d249d25 --- /dev/null +++ b/modules/markup/markdown/markdown.go @@ -0,0 +1,303 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "fmt" + "html/template" + "io" + "strings" + "sync" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/markup/markdown/callout" + "code.gitea.io/gitea/modules/markup/markdown/math" + "code.gitea.io/gitea/modules/setting" + giteautil "code.gitea.io/gitea/modules/util" + + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +var ( + specMarkdown goldmark.Markdown + specMarkdownOnce sync.Once +) + +var ( + renderContextKey = parser.NewContextKey() + renderConfigKey = parser.NewContextKey() +) + +type limitWriter struct { + w io.Writer + sum int64 + limit int64 +} + +// Write implements the standard Write interface: +func (l *limitWriter) Write(data []byte) (int, error) { + leftToWrite := l.limit - l.sum + if leftToWrite < int64(len(data)) { + n, err := l.w.Write(data[:leftToWrite]) + l.sum += int64(n) + if err != nil { + return n, err + } + return n, fmt.Errorf("rendered content too large - truncating render") + } + n, err := l.w.Write(data) + l.sum += int64(n) + return n, err +} + +// newParserContext creates a parser.Context with the render context set +func newParserContext(ctx *markup.RenderContext) parser.Context { + pc := parser.NewContext(parser.WithIDs(newPrefixedIDs())) + pc.Set(renderContextKey, ctx) + return pc +} + +// SpecializedMarkdown sets up the Gitea specific markdown extensions +func SpecializedMarkdown() goldmark.Markdown { + specMarkdownOnce.Do(func() { + specMarkdown = goldmark.New( + goldmark.WithExtensions( + extension.NewTable( + extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)), + extension.Strikethrough, + extension.TaskList, + extension.DefinitionList, + common.FootnoteExtension, + highlighting.NewHighlighting( + highlighting.WithFormatOptions( + chromahtml.WithClasses(true), + chromahtml.PreventSurroundingPre(true), + ), + highlighting.WithWrapperRenderer(func(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) { + if entering { + language, _ := c.Language() + if language == nil { + language = []byte("text") + } + + languageStr := string(language) + + preClasses := []string{"code-block"} + if languageStr == "mermaid" || languageStr == "math" { + preClasses = append(preClasses, "is-loading") + } + + _, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`) + if err != nil { + return + } + + // include language-x class as part of commonmark spec + // the "display" class is used by "js/markup/math.js" to render the code element as a block + _, err = w.WriteString(`<code class="chroma language-` + string(language) + ` display">`) + if err != nil { + return + } + } else { + _, err := w.WriteString("</code></pre>") + if err != nil { + return + } + } + }), + ), + math.NewExtension( + math.Enabled(setting.Markdown.EnableMath), + ), + ), + goldmark.WithParserOptions( + parser.WithAttribute(), + parser.WithAutoHeadingID(), + parser.WithASTTransformers( + util.Prioritized(&callout.GitHubLegacyCalloutTransformer{}, 8000), + util.Prioritized(&callout.GitHubCalloutTransformer{}, 9000), + util.Prioritized(&ASTTransformer{}, 10000), + ), + ), + goldmark.WithRendererOptions( + html.WithUnsafe(), + ), + ) + + // Override the original Tasklist renderer! + specMarkdown.Renderer().AddOptions( + renderer.WithNodeRenderers( + util.Prioritized(callout.NewGitHubCalloutHTMLRenderer(), 10), + util.Prioritized(NewHTMLRenderer(), 10), + ), + ) + }) + return specMarkdown +} + +// actualRender renders Markdown to HTML without handling special links. +func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + converter := SpecializedMarkdown() + lw := &limitWriter{ + w: output, + limit: setting.UI.MaxDisplayFileSize * 3, + } + + // FIXME: should we include a timeout to abort the renderer if it takes too long? + defer func() { + err := recover() + if err == nil { + return + } + + log.Warn("Unable to render markdown due to panic in goldmark: %v", err) + if log.IsDebug() { + log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2)) + } + }() + + // FIXME: Don't read all to memory, but goldmark doesn't support + pc := newParserContext(ctx) + buf, err := io.ReadAll(input) + if err != nil { + log.Error("Unable to ReadAll: %v", err) + return err + } + buf = giteautil.NormalizeEOL(buf) + + // Preserve original length. + bufWithMetadataLength := len(buf) + + rc := &RenderConfig{ + Meta: markup.RenderMetaAsDetails, + Icon: "table", + Lang: "", + } + buf, _ = ExtractMetadataBytes(buf, rc) + + metaLength := bufWithMetadataLength - len(buf) + if metaLength < 0 { + metaLength = 0 + } + rc.metaLength = metaLength + + pc.Set(renderConfigKey, rc) + + if err := converter.Convert(buf, lw, parser.WithContext(pc)); err != nil { + log.Error("Unable to render: %v", err) + return err + } + + return nil +} + +// Note: The output of this method must get sanitized. +func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + defer func() { + err := recover() + if err == nil { + return + } + + log.Warn("Unable to render markdown due to panic in goldmark - will return raw bytes") + if log.IsDebug() { + log.Debug("Panic in markdown: %v\n%s", err, log.Stack(2)) + } + _, err = io.Copy(output, input) + if err != nil { + log.Error("io.Copy failed: %v", err) + } + }() + return actualRender(ctx, input, output) +} + +// MarkupName describes markup's name +var MarkupName = "markdown" + +func init() { + markup.RegisterRenderer(Renderer{}) +} + +// Renderer implements markup.Renderer +type Renderer struct{} + +var _ markup.PostProcessRenderer = (*Renderer)(nil) + +// Name implements markup.Renderer +func (Renderer) Name() string { + return MarkupName +} + +// NeedPostProcess implements markup.PostProcessRenderer +func (Renderer) NeedPostProcess() bool { return true } + +// Extensions implements markup.Renderer +func (Renderer) Extensions() []string { + return setting.Markdown.FileExtensions +} + +// SanitizerRules implements markup.Renderer +func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { + return []setting.MarkupSanitizerRule{} +} + +// Render implements markup.Renderer +func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + return render(ctx, input, output) +} + +// Render renders Markdown to HTML with all specific handling stuff. +func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + if ctx.Type == "" { + ctx.Type = MarkupName + } + return markup.Render(ctx, input, output) +} + +// RenderString renders Markdown string to HTML with all specific handling stuff and return string +func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) { + var buf strings.Builder + if err := Render(ctx, strings.NewReader(content), &buf); err != nil { + return "", err + } + return template.HTML(buf.String()), nil +} + +// RenderRaw renders Markdown to HTML without handling special links. +func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + rd, wr := io.Pipe() + defer func() { + _ = rd.Close() + _ = wr.Close() + }() + + go func() { + if err := render(ctx, input, wr); err != nil { + _ = wr.CloseWithError(err) + return + } + _ = wr.Close() + }() + + return markup.SanitizeReader(rd, "", output) +} + +// RenderRawString renders Markdown to HTML without handling special links and return string +func RenderRawString(ctx *markup.RenderContext, content string) (string, error) { + var buf strings.Builder + if err := RenderRaw(ctx, strings.NewReader(content), &buf); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go new file mode 100644 index 0000000..cc2beca --- /dev/null +++ b/modules/markup/markdown/markdown_test.go @@ -0,0 +1,1365 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown_test + +import ( + "context" + "html/template" + "os" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + AppURL = "http://localhost:3000/" + FullURL = AppURL + "gogits/gogs/" +) + +// these values should match the const above +var localMetas = map[string]string{ + "user": "gogits", + "repo": "gogs", + "repoPath": "../../../tests/gitea-repositories-meta/user13/repo11.git/", +} + +func TestMain(m *testing.M) { + unittest.InitSettings() + if err := git.InitSimple(context.Background()); err != nil { + log.Fatal("git init failed, err: %v", err) + } + markup.Init(&markup.ProcessorHelper{ + IsUsernameMentionable: func(ctx context.Context, username string) bool { + return username == "r-lyeh" + }, + }) + os.Exit(m.Run()) +} + +func TestRender_StandardLinks(t *testing.T) { + setting.AppURL = AppURL + + test := func(input, expected, expectedWiki string) { + buffer, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + }, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + + buffer, err = markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + }, + IsWiki: true, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer))) + } + + googleRendered := `<p><a href="https://google.com/" rel="nofollow">https://google.com/</a></p>` + test("<https://google.com/>", googleRendered, googleRendered) + + lnk := util.URLJoin(FullURL, "WikiPage") + lnkWiki := util.URLJoin(FullURL, "wiki", "WikiPage") + test("[WikiPage](WikiPage)", + `<p><a href="`+lnk+`" rel="nofollow">WikiPage</a></p>`, + `<p><a href="`+lnkWiki+`" rel="nofollow">WikiPage</a></p>`) +} + +func TestRender_Images(t *testing.T) { + setting.AppURL = AppURL + + test := func(input, expected string) { + buffer, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + }, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + } + + url := "../../.images/src/02/train.jpg" + title := "Train" + href := "https://gitea.io" + result := util.URLJoin(FullURL, url) + // hint: With Markdown v2.5.2, there is a new syntax: [link](URL){:target="_blank"} , but we do not support it now + + test( + "!["+title+"]("+url+")", + `<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`) + + test( + "[["+title+"|"+url+"]]", + `<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`) + test( + "[!["+title+"]("+url+")]("+href+")", + `<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`) + + test( + "!["+title+"]("+url+")", + `<p><a href="`+result+`" target="_blank" rel="nofollow noopener"><img src="`+result+`" alt="`+title+`"/></a></p>`) + + test( + "[["+title+"|"+url+"]]", + `<p><a href="`+result+`" rel="nofollow"><img src="`+result+`" title="`+title+`" alt="`+title+`"/></a></p>`) + test( + "[!["+title+"]("+url+")]("+href+")", + `<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`) +} + +func testAnswers(baseURLContent, baseURLImages string) []string { + return []string{ + `<p>Wiki! Enjoy :)</p> +<ul> +<li><a href="` + baseURLContent + `/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li> +<li><a href="` + baseURLContent + `/Tips" rel="nofollow">Tips</a></li> +</ul> +<p>See commit <a href="/gogits/gogs/commit/65f1bf27bc" rel="nofollow"><code>65f1bf27bc</code></a></p> +<p>Ideas and codes</p> +<ul> +<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/ocornut/imgui/issues/786" class="ref-issue" rel="nofollow">ocornut/imgui#786</a></li> +<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/gogits/gogs/issues/786" class="ref-issue" rel="nofollow">#786</a></li> +<li>Node graph editors <a href="https://github.com/ocornut/imgui/issues/306" rel="nofollow">https://github.com/ocornut/imgui/issues/306</a></li> +<li><a href="` + baseURLContent + `/memory_editor_example" rel="nofollow">Memory Editor</a></li> +<li><a href="` + baseURLContent + `/plot_var_example" rel="nofollow">Plot var helper</a></li> +</ul> +`, + `<h2 id="user-content-what-is-wine-staging">What is Wine Staging?</h2> +<p><strong>Wine Staging</strong> on website <a href="http://wine-staging.com" rel="nofollow">wine-staging.com</a>.</p> +<h2 id="user-content-quick-links">Quick Links</h2> +<p>Here are some links to the most important topics. You can find the full list of pages at the sidebar.</p> +<table> +<thead> +<tr> +<th><a href="` + baseURLImages + `/images/icon-install.png" rel="nofollow"><img src="` + baseURLImages + `/images/icon-install.png" title="icon-install.png" alt="images/icon-install.png"/></a></th> +<th><a href="` + baseURLContent + `/Installation" rel="nofollow">Installation</a></th> +</tr> +</thead> +<tbody> +<tr> +<td><a href="` + baseURLImages + `/images/icon-usage.png" rel="nofollow"><img src="` + baseURLImages + `/images/icon-usage.png" title="icon-usage.png" alt="images/icon-usage.png"/></a></td> +<td><a href="` + baseURLContent + `/Usage" rel="nofollow">Usage</a></td> +</tr> +</tbody> +</table> +`, + `<p><a href="http://www.excelsiorjet.com/" rel="nofollow">Excelsior JET</a> allows you to create native executables for Windows, Linux and Mac OS X.</p> +<ol> +<li><a href="https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop" rel="nofollow">Package your libGDX application</a><br/> +<a href="` + baseURLImages + `/images/1.png" rel="nofollow"><img src="` + baseURLImages + `/images/1.png" title="1.png" alt="images/1.png"/></a></li> +<li>Perform a test run by hitting the Run! button.<br/> +<a href="` + baseURLImages + `/images/2.png" rel="nofollow"><img src="` + baseURLImages + `/images/2.png" title="2.png" alt="images/2.png"/></a></li> +</ol> +<h2 id="user-content-custom-id">More tests</h2> +<p>(from <a href="https://www.markdownguide.org/extended-syntax/" rel="nofollow">https://www.markdownguide.org/extended-syntax/</a>)</p> +<h3 id="user-content-checkboxes">Checkboxes</h3> +<ul> +<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="434"/>unchecked</li> +<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> +<h3 id="user-content-definition-list">Definition list</h3> +<dl> +<dt>First Term</dt> +<dd>This is the definition of the first term.</dd> +<dt>Second Term</dt> +<dd>This is one definition of the second term.</dd> +<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> +<div> +<hr/> +<ol> +<li id="fn:user-content-1"> +<p>This is the first footnote. <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p> +</li> +<li id="fn:user-content-bignote"> +<p>Here is one with multiple paragraphs and code.</p> +<p>Indent paragraphs to include them in the footnote.</p> +<p><code>{ my code }</code></p> +<p>Add as many paragraphs as you like. <a href="#fnref:user-content-bignote" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, `<ul> +<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="3"/> If you want to rebase/retry this PR, click this checkbox.</li> +</ul> +<hr/> +<p>This PR has been generated by <a href="https://github.com/renovatebot/renovate" rel="nofollow">Renovate Bot</a>.</p> +`, + } +} + +// Test cases without ambiguous links +var sameCases = []string{ + // dear imgui wiki markdown extract: special wiki syntax + `Wiki! Enjoy :) +- [[Links, Language bindings, Engine bindings|Links]] +- [[Tips]] + +See commit 65f1bf27bc + +Ideas and codes + +- Bezier widget (by @r-lyeh) ` + AppURL + `ocornut/imgui/issues/786 +- Bezier widget (by @r-lyeh) ` + AppURL + `gogits/gogs/issues/786 +- Node graph editors https://github.com/ocornut/imgui/issues/306 +- [[Memory Editor|memory_editor_example]] +- [[Plot var helper|plot_var_example]]`, + // wine-staging wiki home extract: tables, special wiki syntax, images + `## What is Wine Staging? +**Wine Staging** on website [wine-staging.com](http://wine-staging.com). + +## Quick Links +Here are some links to the most important topics. You can find the full list of pages at the sidebar. + +| [[images/icon-install.png]] | [[Installation]] | +|--------------------------------|----------------------------------------------------------| +| [[images/icon-usage.png]] | [[Usage]] | +`, + // libgdx wiki page: inline images with special syntax + `[Excelsior JET](http://www.excelsiorjet.com/) allows you to create native executables for Windows, Linux and Mac OS X. + +1. [Package your libGDX application](https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop) +[[images/1.png]] +2. Perform a test run by hitting the Run! button. +[[images/2.png]] + +## More tests {#custom-id} + +(from https://www.markdownguide.org/extended-syntax/) + +### Checkboxes + +- [ ] unchecked +- [x] checked +- [ ] still unchecked + +### Definition list + +First Term +: This is the definition of the first term. + +Second Term +: This is one definition of the second term. +: This is another definition of the second term. + +### Footnotes + +Here is a simple footnote,[^1] and here is a longer one.[^bignote] + +[^1]: This is the first footnote. + +[^bignote]: Here is one with multiple paragraphs and code. + + Indent paragraphs to include them in the footnote. + + ` + "`{ my code }`" + ` + + Add as many paragraphs as you like. +`, + ` +- [ ] <!-- rebase-check --> If you want to rebase/retry this PR, click this checkbox. + +--- + +This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). + +<!-- test-comment -->`, +} + +func TestTotal_RenderWiki(t *testing.T) { + setting.AppURL = AppURL + + answers := testAnswers(util.URLJoin(FullURL, "wiki"), util.URLJoin(FullURL, "wiki", "raw")) + + for i := 0; i < len(sameCases); i++ { + line, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + }, + Metas: localMetas, + IsWiki: true, + }, sameCases[i]) + require.NoError(t, err) + assert.Equal(t, template.HTML(answers[i]), line) + } + + testCases := []string{ + // Guard wiki sidebar: special syntax + `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`, + // rendered + `<p><a href="` + FullURL + `wiki/Guardfile-DSL---Configuring-Guard" rel="nofollow">Guardfile-DSL / Configuring-Guard</a></p> +`, + // special syntax + `[[Name|Link]]`, + // rendered + `<p><a href="` + FullURL + `wiki/Link" rel="nofollow">Name</a></p> +`, + } + + for i := 0; i < len(testCases); i += 2 { + line, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + }, + IsWiki: true, + }, testCases[i]) + require.NoError(t, err) + assert.Equal(t, template.HTML(testCases[i+1]), line) + } +} + +func TestTotal_RenderString(t *testing.T) { + setting.AppURL = AppURL + + answers := testAnswers(util.URLJoin(FullURL, "src", "master"), util.URLJoin(FullURL, "media", "master")) + + for i := 0; i < len(sameCases); i++ { + line, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + BranchPath: "master", + }, + Metas: localMetas, + }, sameCases[i]) + require.NoError(t, err) + assert.Equal(t, template.HTML(answers[i]), line) + } + + testCases := []string{} + + for i := 0; i < len(testCases); i += 2 { + line, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + }, + }, testCases[i]) + require.NoError(t, err) + assert.Equal(t, template.HTML(testCases[i+1]), line) + } +} + +func TestRender_RenderParagraphs(t *testing.T) { + test := func(t *testing.T, str string, cnt int) { + res, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, str) + require.NoError(t, err) + assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for unix should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res) + + mac := strings.ReplaceAll(str, "\n", "\r") + res, err = markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, mac) + require.NoError(t, err) + assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for mac should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res) + + dos := strings.ReplaceAll(str, "\n", "\r\n") + res, err = markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, dos) + require.NoError(t, err) + assert.Equal(t, cnt, strings.Count(res, "<p"), "Rendered result for windows should have %d paragraph(s) but has %d:\n%s\n", cnt, strings.Count(res, "<p"), res) + } + + test(t, "\nOne\nTwo\nThree", 1) + test(t, "\n\nOne\nTwo\nThree", 1) + test(t, "\n\nOne\nTwo\nThree\n\n\n", 1) + test(t, "A\n\nB\nC\n", 2) + test(t, "A\n\n\nB\nC\n", 2) +} + +func TestMarkdownRenderRaw(t *testing.T) { + testcases := [][]byte{ + { // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6267570554535936 + 0x2a, 0x20, 0x2d, 0x0a, 0x09, 0x20, 0x60, 0x5b, 0x0a, 0x09, 0x20, 0x60, + 0x5b, + }, + { // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6278827345051648 + 0x2d, 0x20, 0x2d, 0x0d, 0x09, 0x60, 0x0d, 0x09, 0x60, + }, + { // clusterfuzz_testcase_minimized_fuzz_markdown_render_raw_6016973788020736[] = { + 0x7b, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x3d, 0x35, 0x7d, 0x0a, 0x3d, + }, + } + + for _, testcase := range testcases { + log.Info("Test markdown render error with fuzzy data: %x, the following errors can be recovered", testcase) + _, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, string(testcase)) + require.NoError(t, err) + } +} + +func TestRenderSiblingImages_Issue12925(t *testing.T) { + testcase := `![image1](/image1) +![image2](/image2) +` + expected := `<p><a href="/image1" target="_blank" rel="nofollow noopener"><img src="/image1" alt="image1"></a><br> +<a href="/image2" target="_blank" rel="nofollow noopener"><img src="/image2" alt="image2"></a></p> +` + res, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, testcase) + require.NoError(t, err) + assert.Equal(t, expected, res) +} + +func TestRenderEmojiInLinks_Issue12331(t *testing.T) { + testcase := `[Link with emoji :moon: in text](https://gitea.io)` + expected := `<p><a href="https://gitea.io" rel="nofollow">Link with emoji <span class="emoji" aria-label="waxing gibbous moon">🌔</span> in text</a></p> +` + res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, testcase) + require.NoError(t, err) + assert.Equal(t, template.HTML(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 := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase) + require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase) + assert.Equal(t, template.HTML(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%)`", // codespell-ignore + // looks like a color but not really + "`hsl(40, 60, 80)`", + } + + for _, test := range negativeTests { + res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test) + require.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 { + testcase string + expected string + }{ + { + "$a$", + `<p><code class="language-math is-loading">a</code></p>` + nl, + }, + { + "$ a $", + `<p><code class="language-math is-loading">a</code></p>` + nl, + }, + { + "$a$ $b$", + `<p><code class="language-math is-loading">a</code> <code class="language-math is-loading">b</code></p>` + nl, + }, + { + `\(a\) \(b\)`, + `<p><code class="language-math is-loading">a</code> <code class="language-math is-loading">b</code></p>` + nl, + }, + { + `$a$.`, + `<p><code class="language-math is-loading">a</code>.</p>` + nl, + }, + { + `.$a$`, + `<p>.$a$</p>` + nl, + }, + { + `$a a$b b$`, + `<p>$a a$b b$</p>` + nl, + }, + { + `a a$b b`, + `<p>a a$b b</p>` + nl, + }, + { + `a$b $a a$b b$`, + `<p>a$b $a a$b b$</p>` + nl, + }, + { + "a$x$", + `<p>a$x$</p>` + nl, + }, + { + "$x$a", + `<p>$x$a</p>` + nl, + }, + { + "$$a$$", + `<pre class="code-block is-loading"><code class="chroma language-math display">a</code></pre>` + nl, + }, + { + `\[a b\]`, + `<pre class="code-block is-loading"><code class="chroma language-math display">a b</code></pre>` + nl, + }, + { + `\[a b]`, + `<p>[a b]</p>` + nl, + }, + { + `$$a`, + `<p>$$a</p>` + nl, + }, + { + "$a$ ($b$) [$c$] {$d$}", + `<p><code class="language-math is-loading">a</code> (<code class="language-math is-loading">b</code>) [$c$] {$d$}</p>` + nl, + }, + { + "$$a$$ test", + `<p><code class="language-math display is-loading">a</code> test</p>` + nl, + }, + { + "test $$a$$", + `<p>test <code class="language-math display is-loading">a</code></p>` + nl, + }, + } + + for _, test := range testcases { + res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase) + require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase) + assert.Equal(t, template.HTML(test.expected), res, "Unexpected result in testcase %q", test.testcase) + } +} + +func TestFootnote(t *testing.T) { + testcases := []struct { + testcase string + expected string + }{ + { + `Citation needed[^0]. +[^0]: Source`, + `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup>.</p> +<div> +<hr/> +<ol> +<li id="fn:user-content-0"> +<p>Source <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Citation needed[^0]`, + `<p>Citation needed[^0]</p> +`, + }, + { + `Citation needed[^1], Citation needed twice[^3] +[^3]: Source`, + `<p>Citation needed[^1], Citation needed twice<sup id="fnref:user-content-3"><a href="#fn:user-content-3" rel="nofollow">1</a></sup></p> +<div> +<hr/> +<ol> +<li id="fn:user-content-3"> +<p>Source <a href="#fnref:user-content-3" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Citation needed[^0] +[^1]: Source`, + `<p>Citation needed[^0]</p> +`, + }, + { + `Citation needed[^0] +[^0]: Source 1 +[^0]: Source 2`, + `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p> +<div> +<hr/> +<ol> +<li id="fn:user-content-0"> +<p>Source 1 <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Citation needed![^0] +[^0]: Source`, + `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p> +<div> +<hr/> +<ol> +<li id="fn:user-content-0"> +<p>Source <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Trigger [^`, + `<p>Trigger [^</p> +`, + }, + { + `Trigger 2 [^0`, + `<p>Trigger 2 [^0</p> +`, + }, + { + `Citation needed[^0] +[^0]: Source with citation needed[^1] +[^1]: Source`, + `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p> +<div> +<hr/> +<ol> +<li id="fn:user-content-0"> +<p>Source with citation needed<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">2</a></sup> <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p> +</li> +<li id="fn:user-content-1"> +<p>Source <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Citation needed[^#] +[^#]: Source`, + `<p>Citation needed<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">1</a></sup></p> +<div> +<hr/> +<ol> +<li id="fn:user-content-1"> +<p>Source <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Citation needed[^0] + [^0]: Source`, + `<p>Citation needed[^0]<br/> +[^0]: Source</p> +`, + }, + { + `[^0]: Source + +Citation needed[^0].`, + `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup>.</p> +<div> +<hr/> +<ol> +<li id="fn:user-content-0"> +<p>Source <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Citation needed[^] +[^]: Source`, + `<p>Citation needed[^]<br/> +[^]: Source</p> +`, + }, + { + `Citation needed[^0] +[^0] Source`, + `<p>Citation needed[^0]<br/> +[^0] Source</p> +`, + }, + { + `Citation needed[^0] +[^0 Source`, + `<p>Citation needed[^0]<br/> +[^0 Source</p> +`, + }, + { + `Citation needed[^0] [^0]: Source`, + `<p>Citation needed[^0] [^0]: Source</p> +`, + }, + { + `Citation needed[^Source here 0 # 9-3] +[^Source here 0 # 9-3]: Source`, + `<p>Citation needed<sup id="fnref:user-content-source-here-0-9-3"><a href="#fn:user-content-source-here-0-9-3" rel="nofollow">1</a></sup></p> +<div> +<hr/> +<ol> +<li id="fn:user-content-source-here-0-9-3"> +<p>Source <a href="#fnref:user-content-source-here-0-9-3" rel="nofollow">↩︎</a></p> +</li> +</ol> +</div> +`, + }, + { + `Citation needed[^0] +[^0]:`, + `<p>Citation needed<sup id="fnref:user-content-0"><a href="#fn:user-content-0" rel="nofollow">1</a></sup></p> +<div> +<hr/> +<ol> +<li id="fn:user-content-0"> + <a href="#fnref:user-content-0" rel="nofollow">↩︎</a></li> +</ol> +</div> +`, + }, + } + for _, test := range testcases { + res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase) + require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase) + assert.Equal(t, test.expected, string(res), "Unexpected result in testcase %q", test.testcase) + } +} + +func TestTaskList(t *testing.T) { + testcases := []struct { + testcase string + expected string + }{ + { + // data-source-position should take into account YAML frontmatter. + `--- +foo: bar +--- +- [ ] task 1`, + `<details><summary><i class="icon table"></i></summary><table> +<thead> +<tr> +<th>foo</th> +</tr> +</thead> +<tbody> +<tr> +<td>bar</td> +</tr> +</tbody> +</table> +</details><ul> +<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="19"/>task 1</li> +</ul> +`, + }, + } + + for _, test := range testcases { + res, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase) + require.NoError(t, err, "Unexpected error in testcase: %q", test.testcase) + assert.Equal(t, template.HTML(test.expected), res, "Unexpected result in testcase %q", test.testcase) + } +} + +func TestRenderLinks(t *testing.T) { + input := ` space @mention-user${SPACE}${SPACE} +/just/a/path.bin +https://example.com/file.bin +[local link](file.bin) +[remote link](https://example.com) +[[local link|file.bin]] +[[remote link|https://example.com]] +![local image](image.jpg) +![local image](path/file) +![local image](/path/file) +![remote image](https://example.com/image.jpg) +[[local image|image.jpg]] +[[remote link|https://example.com/image.jpg]] +https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare +https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit +:+1: +mail@domain.com +@mention-user test +#123 + space${SPACE}${SPACE} +` + input = strings.ReplaceAll(input, "${SPACE}", " ") // replace ${SPACE} with " ", to avoid some editor's auto-trimming + cases := []struct { + Links markup.Links + IsWiki bool + Expected string + }{ + { // 0 + Links: markup.Links{}, + IsWiki: false, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/src/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a><br/> +<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a><br/> +<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 1 + Links: markup.Links{}, + IsWiki: true, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/wiki/raw/image.jpg" alt="local image"/></a><br/> +<a href="/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/wiki/raw/image.jpg" rel="nofollow"><img src="/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 2 + Links: markup.Links{ + Base: "https://gitea.io/", + }, + IsWiki: false, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="https://gitea.io/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="https://gitea.io/src/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="https://gitea.io/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/image.jpg" alt="local image"/></a><br/> +<a href="https://gitea.io/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/path/file" alt="local image"/></a><br/> +<a href="https://gitea.io/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="https://gitea.io/image.jpg" rel="nofollow"><img src="https://gitea.io/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 3 + Links: markup.Links{ + Base: "https://gitea.io/", + }, + IsWiki: true, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="https://gitea.io/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="https://gitea.io/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="https://gitea.io/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/wiki/raw/image.jpg" alt="local image"/></a><br/> +<a href="https://gitea.io/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="https://gitea.io/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="https://gitea.io/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="https://gitea.io/wiki/raw/image.jpg" rel="nofollow"><img src="https://gitea.io/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 4 + Links: markup.Links{ + Base: "/relative/path", + }, + IsWiki: false, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/relative/path/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/src/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/image.jpg" alt="local image"/></a><br/> +<a href="/relative/path/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/path/file" alt="local image"/></a><br/> +<a href="/relative/path/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/relative/path/image.jpg" rel="nofollow"><img src="/relative/path/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 5 + Links: markup.Links{ + Base: "/relative/path", + }, + IsWiki: true, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 6 + Links: markup.Links{ + Base: "/user/repo", + BranchPath: "branch/main", + }, + IsWiki: false, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/user/repo/src/branch/main/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/user/repo/src/branch/main/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/user/repo/media/branch/main/image.jpg" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/image.jpg" alt="local image"/></a><br/> +<a href="/user/repo/media/branch/main/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/path/file" alt="local image"/></a><br/> +<a href="/user/repo/media/branch/main/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/user/repo/media/branch/main/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 7 + Links: markup.Links{ + Base: "/relative/path", + BranchPath: "branch/main", + }, + IsWiki: true, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 8 + Links: markup.Links{ + Base: "/user/repo", + TreePath: "sub/folder", + }, + IsWiki: false, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/user/repo/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/user/repo/src/sub/folder/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/user/repo/image.jpg" target="_blank" rel="nofollow noopener"><img src="/user/repo/image.jpg" alt="local image"/></a><br/> +<a href="/user/repo/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/path/file" alt="local image"/></a><br/> +<a href="/user/repo/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/user/repo/image.jpg" rel="nofollow"><img src="/user/repo/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 9 + Links: markup.Links{ + Base: "/relative/path", + TreePath: "sub/folder", + }, + IsWiki: true, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 10 + Links: markup.Links{ + Base: "/user/repo", + BranchPath: "branch/main", + TreePath: "sub/folder", + }, + IsWiki: false, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/user/repo/src/branch/main/sub/folder/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/user/repo/src/branch/main/sub/folder/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/user/repo/media/branch/main/sub/folder/image.jpg" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/sub/folder/image.jpg" alt="local image"/></a><br/> +<a href="/user/repo/media/branch/main/sub/folder/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/sub/folder/path/file" alt="local image"/></a><br/> +<a href="/user/repo/media/branch/main/sub/folder/path/file" target="_blank" rel="nofollow noopener"><img src="/user/repo/media/branch/main/sub/folder/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/user/repo/media/branch/main/sub/folder/image.jpg" rel="nofollow"><img src="/user/repo/media/branch/main/sub/folder/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + { // 11 + Links: markup.Links{ + Base: "/relative/path", + BranchPath: "branch/main", + TreePath: "sub/folder", + }, + IsWiki: true, + Expected: `<p>space @mention-user<br/> +/just/a/path.bin<br/> +<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/file.bin" rel="nofollow">local link</a><br/> +<a href="https://example.com" rel="nofollow">remote link</a><br/> +<a href="/relative/path/wiki/raw/image.jpg" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/image.jpg" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="/relative/path/wiki/raw/path/file" target="_blank" rel="nofollow noopener"><img src="/relative/path/wiki/raw/path/file" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/> +<a href="/relative/path/wiki/raw/image.jpg" rel="nofollow"><img src="/relative/path/wiki/raw/image.jpg" title="local image" alt="local image"/></a><br/> +<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/> +<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow">https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/> +<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow">https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb</a><br/> +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/> +<span class="emoji" aria-label="thumbs up">👍</span><br/> +<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/> +@mention-user test<br/> +#123<br/> +space</p> +`, + }, + } + + for i, c := range cases { + result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background(), Links: c.Links, IsWiki: c.IsWiki}, input) + require.NoError(t, err, "Unexpected error in testcase: %v", i) + assert.Equal(t, template.HTML(c.Expected), result, "Unexpected result in testcase %v", i) + } +} + +func TestCustomMarkdownURL(t *testing.T) { + defer test.MockVariableValue(&setting.Markdown.CustomURLSchemes, []string{"abp"})() + setting.AppURL = AppURL + + test := func(input, expected string) { + buffer, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: FullURL, + BranchPath: "branch/main", + }, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + } + + test("[test](abp:subscribe?location=https://codeberg.org/filters.txt&title=joy)", + `<p><a href="abp:subscribe?location=https://codeberg.org/filters.txt&title=joy" rel="nofollow">test</a></p>`) + + // Ensure that the schema itself without `:` is still made absolute. + test("[test](abp)", + `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/abp" rel="nofollow">test</a></p>`) +} + +func TestYAMLMeta(t *testing.T) { + setting.AppURL = AppURL + + test := func(input, expected string) { + buffer, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + } + + test(`--- +include_toc: true +--- +## Header`, + `<details><summary><i class="icon table"></i></summary><table> +<thead> +<tr> +<th>include_toc</th> +</tr> +</thead> +<tbody> +<tr> +<td>true</td> +</tr> +</tbody> +</table> +</details><details><summary>toc</summary><ul> +<li> +<a href="#user-content-header" rel="nofollow">Header</a></li> +</ul> +</details><h2 id="user-content-header">Header</h2>`) + + test(`--- +key: value +---`, + `<details><summary><i class="icon table"></i></summary><table> +<thead> +<tr> +<th>key</th> +</tr> +</thead> +<tbody> +<tr> +<td>value</td> +</tr> +</tbody> +</table> +</details>`) + + test("---\n---\n", + `<hr/> +<hr/>`) + + test(`--- +gitea: + details_icon: smiley + include_toc: true +--- +# Another header`, + `<details><summary><i class="icon smiley"></i></summary><table> +<thead> +<tr> +<th>gitea</th> +</tr> +</thead> +<tbody> +<tr> +<td><table> +<thead> +<tr> +<th>details_icon</th> +<th>include_toc</th> +</tr> +</thead> +<tbody> +<tr> +<td>smiley</td> +<td>true</td> +</tr> +</tbody> +</table> +</td> +</tr> +</tbody> +</table> +</details><details><summary>toc</summary><ul> +<li> +<a href="#user-content-another-header" rel="nofollow">Another header</a></li> +</ul> +</details><h1 id="user-content-another-header">Another header</h1>`) + + test(`--- +gitea: + meta: table +key: value +---`, `<table> +<thead> +<tr> +<th>gitea</th> +<th>key</th> +</tr> +</thead> +<tbody> +<tr> +<td><table> +<thead> +<tr> +<th>meta</th> +</tr> +</thead> +<tbody> +<tr> +<td>table</td> +</tr> +</tbody> +</table> +</td> +<td>value</td> +</tr> +</tbody> +</table>`) +} + +func TestCallout(t *testing.T) { + setting.AppURL = AppURL + + test := func(input, expected string) { + buffer, err := markdown.RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + } + + test(">\n0", "<blockquote>\n</blockquote>\n<p>0</p>") + test("> **Warning**\n> Bad stuff is brewing here", `<blockquote class="attention-header attention-warning"><p class="attention-title"><strong class="attention-warning">Warning</strong></p> +<p>Bad stuff is brewing here</p> +</blockquote>`) + test("> [!WARNING]\n> Bad stuff is brewing here", `<blockquote class="attention-header attention-warning"><p class="attention-title"><strong class="attention-warning">Warning</strong></p> +<p>Bad stuff is brewing here</p> +</blockquote>`) +} diff --git a/modules/markup/markdown/math/block_node.go b/modules/markup/markdown/math/block_node.go new file mode 100644 index 0000000..10d17ff --- /dev/null +++ b/modules/markup/markdown/math/block_node.go @@ -0,0 +1,41 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import "github.com/yuin/goldmark/ast" + +// Block represents a display math block e.g. $$...$$ or \[...\] +type Block struct { + ast.BaseBlock + Dollars bool + Indent int + Closed bool +} + +// KindBlock is the node kind for math blocks +var KindBlock = ast.NewNodeKind("MathBlock") + +// NewBlock creates a new math Block +func NewBlock(dollars bool, indent int) *Block { + return &Block{ + Dollars: dollars, + Indent: indent, + } +} + +// Dump dumps the block to a string +func (n *Block) Dump(source []byte, level int) { + m := map[string]string{} + ast.DumpHelper(n, source, level, m, nil) +} + +// Kind returns KindBlock for math Blocks +func (n *Block) Kind() ast.NodeKind { + return KindBlock +} + +// IsRaw returns true as this block should not be processed further +func (n *Block) IsRaw() bool { + return true +} diff --git a/modules/markup/markdown/math/block_parser.go b/modules/markup/markdown/math/block_parser.go new file mode 100644 index 0000000..527df84 --- /dev/null +++ b/modules/markup/markdown/math/block_parser.go @@ -0,0 +1,125 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import ( + "bytes" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type blockParser struct { + parseDollars bool +} + +// NewBlockParser creates a new math BlockParser +func NewBlockParser(parseDollarBlocks bool) parser.BlockParser { + return &blockParser{ + parseDollars: parseDollarBlocks, + } +} + +// Open parses the current line and returns a result of parsing. +func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { + line, segment := reader.PeekLine() + pos := pc.BlockOffset() + if pos == -1 || len(line[pos:]) < 2 { + return nil, parser.NoChildren + } + + dollars := false + if b.parseDollars && line[pos] == '$' && line[pos+1] == '$' { + dollars = true + } else if line[pos] != '\\' || line[pos+1] != '[' { + return nil, parser.NoChildren + } + + node := NewBlock(dollars, pos) + + // Now we need to check if the ending block is on the segment... + endBytes := []byte{'\\', ']'} + if dollars { + endBytes = []byte{'$', '$'} + } + idx := bytes.Index(line[pos+2:], endBytes) + if idx >= 0 { + // for case $$ ... $$ any other text + for i := pos + idx + 4; i < len(line); i++ { + if line[i] != ' ' && line[i] != '\n' { + return nil, parser.NoChildren + } + } + segment.Stop = segment.Start + idx + 2 + reader.Advance(segment.Len() - 1) + segment.Start += 2 + node.Lines().Append(segment) + node.Closed = true + return node, parser.Close | parser.NoChildren + } + + return nil, parser.NoChildren +} + +// Continue parses the current line and returns a result of parsing. +func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { + block := node.(*Block) + if block.Closed { + return parser.Close + } + + line, segment := reader.PeekLine() + w, pos := util.IndentWidth(line, 0) + if w < 4 { + if block.Dollars { + i := pos + for ; i < len(line) && line[i] == '$'; i++ { + } + length := i - pos + if length >= 2 && util.IsBlank(line[i:]) { + reader.Advance(segment.Stop - segment.Start - segment.Padding) + block.Closed = true + return parser.Close + } + } else if len(line[pos:]) > 1 && line[pos] == '\\' && line[pos+1] == ']' && util.IsBlank(line[pos+2:]) { + reader.Advance(segment.Stop - segment.Start - segment.Padding) + block.Closed = true + return parser.Close + } + } + + pos, padding := util.IndentPosition(line, 0, block.Indent) + seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding) + node.Lines().Append(seg) + reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding) + return parser.Continue | parser.NoChildren +} + +// Close will be called when the parser returns Close. +func (b *blockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { + // noop +} + +// CanInterruptParagraph returns true if the parser can interrupt paragraphs, +// otherwise false. +func (b *blockParser) CanInterruptParagraph() bool { + return true +} + +// CanAcceptIndentedLine returns true if the parser can open new node when +// the given line is being indented more than 3 spaces. +func (b *blockParser) CanAcceptIndentedLine() bool { + return false +} + +// Trigger returns a list of characters that triggers Parse method of +// this parser. +// If Trigger returns a nil, Open will be called with any lines. +// +// We leave this as nil as our parse method is quick enough +func (b *blockParser) Trigger() []byte { + return nil +} diff --git a/modules/markup/markdown/math/block_renderer.go b/modules/markup/markdown/math/block_renderer.go new file mode 100644 index 0000000..84817ef --- /dev/null +++ b/modules/markup/markdown/math/block_renderer.go @@ -0,0 +1,42 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import ( + gast "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// BlockRenderer represents a renderer for math Blocks +type BlockRenderer struct{} + +// NewBlockRenderer creates a new renderer for math Blocks +func NewBlockRenderer() renderer.NodeRenderer { + return &BlockRenderer{} +} + +// RegisterFuncs registers the renderer for math Blocks +func (r *BlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindBlock, r.renderBlock) +} + +func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node) { + l := n.Lines().Len() + for i := 0; i < l; i++ { + line := n.Lines().At(i) + _, _ = w.Write(util.EscapeHTML(line.Value(source))) + } +} + +func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) { + n := node.(*Block) + if entering { + _, _ = w.WriteString(`<pre class="code-block is-loading"><code class="chroma language-math display">`) + r.writeLines(w, source, n) + } else { + _, _ = w.WriteString(`</code></pre>` + "\n") + } + return gast.WalkContinue, nil +} diff --git a/modules/markup/markdown/math/inline_block_node.go b/modules/markup/markdown/math/inline_block_node.go new file mode 100644 index 0000000..c92d0c8 --- /dev/null +++ b/modules/markup/markdown/math/inline_block_node.go @@ -0,0 +1,31 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import ( + "github.com/yuin/goldmark/ast" +) + +// InlineBlock represents inline math e.g. $$...$$ +type InlineBlock struct { + Inline +} + +// InlineBlock implements InlineBlock. +func (n *InlineBlock) InlineBlock() {} + +// KindInlineBlock is the kind for math inline block +var KindInlineBlock = ast.NewNodeKind("MathInlineBlock") + +// Kind returns KindInlineBlock +func (n *InlineBlock) Kind() ast.NodeKind { + return KindInlineBlock +} + +// NewInlineBlock creates a new ast math inline block node +func NewInlineBlock() *InlineBlock { + return &InlineBlock{ + Inline{}, + } +} diff --git a/modules/markup/markdown/math/inline_node.go b/modules/markup/markdown/math/inline_node.go new file mode 100644 index 0000000..2221a25 --- /dev/null +++ b/modules/markup/markdown/math/inline_node.go @@ -0,0 +1,48 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import ( + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/util" +) + +// Inline represents inline math e.g. $...$ or \(...\) +type Inline struct { + ast.BaseInline +} + +// Inline implements Inline.Inline. +func (n *Inline) Inline() {} + +// IsBlank returns if this inline node is empty +func (n *Inline) IsBlank(source []byte) bool { + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + text := c.(*ast.Text).Segment + if !util.IsBlank(text.Value(source)) { + return false + } + } + return true +} + +// Dump renders this inline math as debug +func (n *Inline) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// KindInline is the kind for math inline +var KindInline = ast.NewNodeKind("MathInline") + +// Kind returns KindInline +func (n *Inline) Kind() ast.NodeKind { + return KindInline +} + +// NewInline creates a new ast math inline node +func NewInline() *Inline { + return &Inline{ + BaseInline: ast.BaseInline{}, + } +} diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go new file mode 100644 index 0000000..b11195d --- /dev/null +++ b/modules/markup/markdown/math/inline_parser.go @@ -0,0 +1,153 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import ( + "bytes" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +type inlineParser struct { + start []byte + end []byte +} + +var defaultInlineDollarParser = &inlineParser{ + start: []byte{'$'}, + end: []byte{'$'}, +} + +var defaultDualDollarParser = &inlineParser{ + start: []byte{'$', '$'}, + end: []byte{'$', '$'}, +} + +// NewInlineDollarParser returns a new inline parser +func NewInlineDollarParser() parser.InlineParser { + return defaultInlineDollarParser +} + +func NewInlineDualDollarParser() parser.InlineParser { + return defaultDualDollarParser +} + +var defaultInlineBracketParser = &inlineParser{ + start: []byte{'\\', '('}, + end: []byte{'\\', ')'}, +} + +// NewInlineDollarParser returns a new inline parser +func NewInlineBracketParser() parser.InlineParser { + return defaultInlineBracketParser +} + +// Trigger triggers this parser on $ or \ +func (parser *inlineParser) Trigger() []byte { + return parser.start +} + +func isPunctuation(b byte) bool { + return b == '.' || b == '!' || b == '?' || b == ',' || b == ';' || b == ':' +} + +func isBracket(b byte) bool { + return b == ')' +} + +func isAlphanumeric(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') +} + +// Parse parses the current line and returns a result of parsing. +func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, _ := block.PeekLine() + + if !bytes.HasPrefix(line, parser.start) { + // We'll catch this one on the next time round + return nil + } + + precedingCharacter := block.PrecendingCharacter() + if precedingCharacter < 256 && (isAlphanumeric(byte(precedingCharacter)) || isPunctuation(byte(precedingCharacter))) { + // need to exclude things like `a$` from being considered a start + return nil + } + + // move the opener marker point at the start of the text + opener := len(parser.start) + + // Now look for an ending line + ender := opener + for { + pos := bytes.Index(line[ender:], parser.end) + if pos < 0 { + return nil + } + + ender += pos + + // Now we want to check the character at the end of our parser section + // that is ender + len(parser.end) and check if char before ender is '\' + pos = ender + len(parser.end) + if len(line) <= pos { + break + } + suceedingCharacter := line[pos] + // check valid ending character + if !isPunctuation(suceedingCharacter) && + !(suceedingCharacter == ' ') && + !(suceedingCharacter == '\n') && + !isBracket(suceedingCharacter) { + return nil + } + if line[ender-1] != '\\' { + break + } + + // move the pointer onwards + ender += len(parser.end) + } + + block.Advance(opener) + _, pos := block.Position() + var node ast.Node + if parser == defaultDualDollarParser { + node = NewInlineBlock() + } else { + node = NewInline() + } + segment := pos.WithStop(pos.Start + ender - opener) + node.AppendChild(node, ast.NewRawTextSegment(segment)) + block.Advance(ender - opener + len(parser.end)) + + if parser == defaultDualDollarParser { + trimBlock(&(node.(*InlineBlock)).Inline, block) + } else { + trimBlock(node.(*Inline), block) + } + return node +} + +func trimBlock(node *Inline, block text.Reader) { + if node.IsBlank(block.Source()) { + return + } + + // trim first space and last space + first := node.FirstChild().(*ast.Text) + if !(!first.Segment.IsEmpty() && block.Source()[first.Segment.Start] == ' ') { + return + } + + last := node.LastChild().(*ast.Text) + if !(!last.Segment.IsEmpty() && block.Source()[last.Segment.Stop-1] == ' ') { + return + } + + first.Segment = first.Segment.WithStart(first.Segment.Start + 1) + last.Segment = last.Segment.WithStop(last.Segment.Stop - 1) +} diff --git a/modules/markup/markdown/math/inline_renderer.go b/modules/markup/markdown/math/inline_renderer.go new file mode 100644 index 0000000..9684809 --- /dev/null +++ b/modules/markup/markdown/math/inline_renderer.go @@ -0,0 +1,51 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import ( + "bytes" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// InlineRenderer is an inline renderer +type InlineRenderer struct{} + +// NewInlineRenderer returns a new renderer for inline math +func NewInlineRenderer() renderer.NodeRenderer { + return &InlineRenderer{} +} + +func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + extraClass := "" + if _, ok := n.(*InlineBlock); ok { + extraClass = "display " + } + _, _ = w.WriteString(`<code class="language-math ` + extraClass + `is-loading">`) + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + segment := c.(*ast.Text).Segment + value := util.EscapeHTML(segment.Value(source)) + if bytes.HasSuffix(value, []byte("\n")) { + _, _ = w.Write(value[:len(value)-1]) + if c != n.LastChild() { + _, _ = w.Write([]byte(" ")) + } + } else { + _, _ = w.Write(value) + } + } + return ast.WalkSkipChildren, nil + } + _, _ = w.WriteString(`</code>`) + return ast.WalkContinue, nil +} + +// RegisterFuncs registers the renderer for inline math nodes +func (r *InlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindInline, r.renderInline) + reg.Register(KindInlineBlock, r.renderInline) +} diff --git a/modules/markup/markdown/math/math.go b/modules/markup/markdown/math/math.go new file mode 100644 index 0000000..3d9f376 --- /dev/null +++ b/modules/markup/markdown/math/math.go @@ -0,0 +1,108 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package math + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// Extension is a math extension +type Extension struct { + enabled bool + parseDollarInline bool + parseDollarBlock bool +} + +// Option is the interface Options should implement +type Option interface { + SetOption(e *Extension) +} + +type extensionFunc func(e *Extension) + +func (fn extensionFunc) SetOption(e *Extension) { + fn(e) +} + +// Enabled enables or disables this extension +func Enabled(enable ...bool) Option { + value := true + if len(enable) > 0 { + value = enable[0] + } + return extensionFunc(func(e *Extension) { + e.enabled = value + }) +} + +// WithInlineDollarParser enables or disables the parsing of $...$ +func WithInlineDollarParser(enable ...bool) Option { + value := true + if len(enable) > 0 { + value = enable[0] + } + return extensionFunc(func(e *Extension) { + e.parseDollarInline = value + }) +} + +// WithBlockDollarParser enables or disables the parsing of $$...$$ +func WithBlockDollarParser(enable ...bool) Option { + value := true + if len(enable) > 0 { + value = enable[0] + } + return extensionFunc(func(e *Extension) { + e.parseDollarBlock = value + }) +} + +// Math represents a math extension with default rendered delimiters +var Math = &Extension{ + enabled: true, + parseDollarBlock: true, + parseDollarInline: true, +} + +// NewExtension creates a new math extension with the provided options +func NewExtension(opts ...Option) *Extension { + r := &Extension{ + enabled: true, + parseDollarBlock: true, + parseDollarInline: true, + } + + for _, o := range opts { + o.SetOption(r) + } + return r +} + +// Extend extends goldmark with our parsers and renderers +func (e *Extension) Extend(m goldmark.Markdown) { + if !e.enabled { + return + } + + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(NewBlockParser(e.parseDollarBlock), 701), + )) + + inlines := []util.PrioritizedValue{ + util.Prioritized(NewInlineBracketParser(), 501), + } + if e.parseDollarInline { + inlines = append(inlines, util.Prioritized(NewInlineDollarParser(), 503), + util.Prioritized(NewInlineDualDollarParser(), 502)) + } + m.Parser().AddOptions(parser.WithInlineParsers(inlines...)) + + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewBlockRenderer(), 501), + util.Prioritized(NewInlineRenderer(), 502), + )) +} diff --git a/modules/markup/markdown/meta.go b/modules/markup/markdown/meta.go new file mode 100644 index 0000000..e76b253 --- /dev/null +++ b/modules/markup/markdown/meta.go @@ -0,0 +1,103 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "errors" + "unicode" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +func isYAMLSeparator(line []byte) bool { + idx := 0 + for ; idx < len(line); idx++ { + if line[idx] >= utf8.RuneSelf { + r, sz := utf8.DecodeRune(line[idx:]) + if !unicode.IsSpace(r) { + return false + } + idx += sz + continue + } + if line[idx] != ' ' { + break + } + } + dashCount := 0 + for ; idx < len(line); idx++ { + if line[idx] != '-' { + break + } + dashCount++ + } + if dashCount < 3 { + return false + } + for ; idx < len(line); idx++ { + if line[idx] >= utf8.RuneSelf { + r, sz := utf8.DecodeRune(line[idx:]) + if !unicode.IsSpace(r) { + return false + } + idx += sz + continue + } + if line[idx] != ' ' { + return false + } + } + return true +} + +// ExtractMetadata consumes a markdown file, parses YAML frontmatter, +// and returns the frontmatter metadata separated from the markdown content +func ExtractMetadata(contents string, out any) (string, error) { + body, err := ExtractMetadataBytes([]byte(contents), out) + return string(body), err +} + +// ExtractMetadata consumes a markdown file, parses YAML frontmatter, +// and returns the frontmatter metadata separated from the markdown content +func ExtractMetadataBytes(contents []byte, out any) ([]byte, error) { + var front, body []byte + + start, end := 0, len(contents) + idx := bytes.IndexByte(contents[start:], '\n') + if idx >= 0 { + end = start + idx + } + line := contents[start:end] + + if !isYAMLSeparator(line) { + return contents, errors.New("frontmatter must start with a separator line") + } + frontMatterStart := end + 1 + for start = frontMatterStart; start < len(contents); start = end + 1 { + end = len(contents) + idx := bytes.IndexByte(contents[start:], '\n') + if idx >= 0 { + end = start + idx + } + line := contents[start:end] + if isYAMLSeparator(line) { + front = contents[frontMatterStart:start] + if end+1 < len(contents) { + body = contents[end+1:] + } + break + } + } + + if len(front) == 0 { + return contents, errors.New("could not determine metadata") + } + + if err := yaml.Unmarshal(front, out); err != nil { + return contents, err + } + return body, nil +} diff --git a/modules/markup/markdown/meta_test.go b/modules/markup/markdown/meta_test.go new file mode 100644 index 0000000..d341ae4 --- /dev/null +++ b/modules/markup/markdown/meta_test.go @@ -0,0 +1,110 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +IssueTemplate is a legacy to keep the unit tests working. +Copied from structs.IssueTemplate, the original type has been changed a lot to support yaml template. +*/ +type IssueTemplate struct { + Name string `json:"name" yaml:"name"` + Title string `json:"title" yaml:"title"` + About string `json:"about" yaml:"about"` + Labels []string `json:"labels" yaml:"labels"` + Ref string `json:"ref" yaml:"ref"` +} + +func (it *IssueTemplate) Valid() bool { + return strings.TrimSpace(it.Name) != "" && strings.TrimSpace(it.About) != "" +} + +func TestExtractMetadata(t *testing.T) { + t.Run("ValidFrontAndBody", func(t *testing.T) { + var meta IssueTemplate + body, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest), &meta) + require.NoError(t, err) + assert.Equal(t, bodyTest, body) + assert.Equal(t, metaTest, meta) + assert.True(t, meta.Valid()) + }) + + t.Run("NoFirstSeparator", func(t *testing.T) { + var meta IssueTemplate + _, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", frontTest, sepTest, bodyTest), &meta) + require.Error(t, err) + }) + + t.Run("NoLastSeparator", func(t *testing.T) { + var meta IssueTemplate + _, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, bodyTest), &meta) + require.Error(t, err) + }) + + t.Run("NoBody", func(t *testing.T) { + var meta IssueTemplate + body, err := ExtractMetadata(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, sepTest), &meta) + require.NoError(t, err) + assert.Equal(t, "", body) + assert.Equal(t, metaTest, meta) + assert.True(t, meta.Valid()) + }) +} + +func TestExtractMetadataBytes(t *testing.T) { + t.Run("ValidFrontAndBody", func(t *testing.T) { + var meta IssueTemplate + body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest)), &meta) + require.NoError(t, err) + assert.Equal(t, bodyTest, string(body)) + assert.Equal(t, metaTest, meta) + assert.True(t, meta.Valid()) + }) + + t.Run("NoFirstSeparator", func(t *testing.T) { + var meta IssueTemplate + _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", frontTest, sepTest, bodyTest)), &meta) + require.Error(t, err) + }) + + t.Run("NoLastSeparator", func(t *testing.T) { + var meta IssueTemplate + _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, bodyTest)), &meta) + require.Error(t, err) + }) + + t.Run("NoBody", func(t *testing.T) { + var meta IssueTemplate + body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, sepTest)), &meta) + require.NoError(t, err) + assert.Equal(t, "", string(body)) + assert.Equal(t, metaTest, meta) + assert.True(t, meta.Valid()) + }) +} + +var ( + sepTest = "-----" + frontTest = `name: Test +about: "A Test" +title: "Test Title" +labels: + - bug + - "test label"` + bodyTest = "This is the body" + metaTest = IssueTemplate{ + Name: "Test", + About: "A Test", + Title: "Test Title", + Labels: []string{"bug", "test label"}, + } +) diff --git a/modules/markup/markdown/prefixed_id.go b/modules/markup/markdown/prefixed_id.go new file mode 100644 index 0000000..63d7fad --- /dev/null +++ b/modules/markup/markdown/prefixed_id.go @@ -0,0 +1,59 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "fmt" + + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/util" + + "github.com/yuin/goldmark/ast" +) + +type prefixedIDs struct { + values container.Set[string] +} + +// Generate generates a new element id. +func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte { + dft := []byte("id") + if kind == ast.KindHeading { + dft = []byte("heading") + } + return p.GenerateWithDefault(value, dft) +} + +// GenerateWithDefault generates a new element id. +func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte { + result := common.CleanValue(value) + if len(result) == 0 { + result = dft + } + if !bytes.HasPrefix(result, []byte("user-content-")) { + result = append([]byte("user-content-"), result...) + } + if p.values.Add(util.UnsafeBytesToString(result)) { + return result + } + for i := 1; ; i++ { + newResult := fmt.Sprintf("%s-%d", result, i) + if p.values.Add(newResult) { + return []byte(newResult) + } + } +} + +// Put puts a given element id to the used ids table. +func (p *prefixedIDs) Put(value []byte) { + p.values.Add(util.UnsafeBytesToString(value)) +} + +func newPrefixedIDs() *prefixedIDs { + return &prefixedIDs{ + values: make(container.Set[string]), + } +} diff --git a/modules/markup/markdown/renderconfig.go b/modules/markup/markdown/renderconfig.go new file mode 100644 index 0000000..f4c48d1 --- /dev/null +++ b/modules/markup/markdown/renderconfig.go @@ -0,0 +1,126 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/modules/markup" + + "github.com/yuin/goldmark/ast" + "gopkg.in/yaml.v3" +) + +// RenderConfig represents rendering configuration for this file +type RenderConfig struct { + Meta markup.RenderMetaMode + Icon string + TOC string // "false": hide, "side"/empty: in sidebar, "main"/"true": in main view + Lang string + yamlNode *yaml.Node + + // Used internally. Cannot be controlled by frontmatter. + metaLength int +} + +func renderMetaModeFromString(s string) markup.RenderMetaMode { + switch strings.TrimSpace(strings.ToLower(s)) { + case "none": + return markup.RenderMetaAsNone + case "table": + return markup.RenderMetaAsTable + default: // "details" + return markup.RenderMetaAsDetails + } +} + +// UnmarshalYAML implement yaml.v3 UnmarshalYAML +func (rc *RenderConfig) UnmarshalYAML(value *yaml.Node) error { + if rc == nil { + return nil + } + + rc.yamlNode = value + + type commonRenderConfig struct { + TOC string `yaml:"include_toc"` + Lang string `yaml:"lang"` + } + var basic commonRenderConfig + if err := value.Decode(&basic); err != nil { + return fmt.Errorf("unable to decode into commonRenderConfig %w", err) + } + + if basic.Lang != "" { + rc.Lang = basic.Lang + } + + rc.TOC = basic.TOC + + type controlStringRenderConfig struct { + Gitea string `yaml:"gitea"` + } + + var stringBasic controlStringRenderConfig + + if err := value.Decode(&stringBasic); err == nil { + if stringBasic.Gitea != "" { + rc.Meta = renderMetaModeFromString(stringBasic.Gitea) + } + return nil + } + + type yamlRenderConfig struct { + Meta *string `yaml:"meta"` + Icon *string `yaml:"details_icon"` + TOC *string `yaml:"include_toc"` + Lang *string `yaml:"lang"` + } + + type yamlRenderConfigWrapper struct { + Gitea *yamlRenderConfig `yaml:"gitea"` + } + + var cfg yamlRenderConfigWrapper + if err := value.Decode(&cfg); err != nil { + return fmt.Errorf("unable to decode into yamlRenderConfigWrapper %w", err) + } + + if cfg.Gitea == nil { + return nil + } + + if cfg.Gitea.Meta != nil { + 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 != "" { + rc.Lang = *cfg.Gitea.Lang + } + + if cfg.Gitea.TOC != nil { + rc.TOC = *cfg.Gitea.TOC + } + + return nil +} + +func (rc *RenderConfig) toMetaNode() ast.Node { + if rc.yamlNode == nil { + return nil + } + switch rc.Meta { + case markup.RenderMetaAsTable: + return nodeToTable(rc.yamlNode) + case markup.RenderMetaAsDetails: + return nodeToDetails(rc.yamlNode, rc.Icon) + default: + return nil + } +} diff --git a/modules/markup/markdown/renderconfig_test.go b/modules/markup/markdown/renderconfig_test.go new file mode 100644 index 0000000..c53acdc --- /dev/null +++ b/modules/markup/markdown/renderconfig_test.go @@ -0,0 +1,162 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestRenderConfig_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + expected *RenderConfig + args string + }{ + { + "empty", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "", + }, "", + }, + { + "lang", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "test", + }, "lang: test", + }, + { + "metatable", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "", + }, "gitea: table", + }, + { + "metanone", &RenderConfig{ + Meta: "none", + Icon: "table", + Lang: "", + }, "gitea: none", + }, + { + "metadetails", &RenderConfig{ + Meta: "details", + Icon: "table", + Lang: "", + }, "gitea: details", + }, + { + "metawrong", &RenderConfig{ + Meta: "details", + Icon: "table", + Lang: "", + }, "gitea: wrong", + }, + { + "toc", &RenderConfig{ + TOC: "true", + Meta: "table", + Icon: "table", + Lang: "", + }, "include_toc: true", + }, + { + "tocfalse", &RenderConfig{ + TOC: "false", + Meta: "table", + Icon: "table", + Lang: "", + }, "include_toc: false", + }, + { + "toclang", &RenderConfig{ + Meta: "table", + Icon: "table", + TOC: "true", + Lang: "testlang", + }, ` + include_toc: true + lang: testlang + `, + }, + { + "complexlang", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + }, ` + gitea: + lang: testlang + `, + }, + { + "complexlang2", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + }, ` + lang: notright + gitea: + lang: testlang +`, + }, + { + "complexlang", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + }, ` + gitea: + lang: testlang +`, + }, + { + "complex2", &RenderConfig{ + Lang: "two", + Meta: "table", + TOC: "true", + Icon: "smiley", + }, ` + lang: one + include_toc: true + gitea: + details_icon: smiley + meta: table + include_toc: true + lang: two +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "", + } + if err := yaml.Unmarshal([]byte(strings.ReplaceAll(tt.args, "\t", " ")), got); err != nil { + t.Errorf("RenderConfig.UnmarshalYAML() error = %v\n%q", err, tt.args) + return + } + + if got.Meta != tt.expected.Meta { + t.Errorf("Meta Expected %s Got %s", tt.expected.Meta, got.Meta) + } + if got.Icon != tt.expected.Icon { + t.Errorf("Icon Expected %s Got %s", tt.expected.Icon, got.Icon) + } + if got.Lang != tt.expected.Lang { + t.Errorf("Lang Expected %s Got %s", tt.expected.Lang, got.Lang) + } + if got.TOC != tt.expected.TOC { + t.Errorf("TOC Expected %q Got %q", tt.expected.TOC, got.TOC) + } + }) + } +} diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/toc.go new file mode 100644 index 0000000..38f744a --- /dev/null +++ b/modules/markup/markdown/toc.go @@ -0,0 +1,54 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "fmt" + "net/url" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/translation" + + "github.com/yuin/goldmark/ast" +) + +func createTOCNode(toc []markup.Header, lang string, detailsAttrs map[string]string) ast.Node { + details := NewDetails() + summary := NewSummary() + + for k, v := range detailsAttrs { + details.SetAttributeString(k, []byte(v)) + } + + summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).TrString("toc")))) + details.AppendChild(details, summary) + ul := ast.NewList('-') + details.AppendChild(details, ul) + currentLevel := 6 + for _, header := range toc { + if header.Level < currentLevel { + currentLevel = header.Level + } + } + for _, header := range toc { + for currentLevel > header.Level { + ul = ul.Parent().(*ast.List) + currentLevel-- + } + for currentLevel < header.Level { + newL := ast.NewList('-') + ul.AppendChild(ul, newL) + currentLevel++ + ul = newL + } + li := ast.NewListItem(currentLevel * 2) + a := ast.NewLink() + a.Destination = []byte(fmt.Sprintf("#%s", url.QueryEscape(header.ID))) + a.AppendChild(a, ast.NewString([]byte(header.Text))) + li.AppendChild(li, a) + ul.AppendChild(ul, li) + } + + return details +} diff --git a/modules/markup/markdown/transform_codespan.go b/modules/markup/markdown/transform_codespan.go new file mode 100644 index 0000000..a2cd4fb --- /dev/null +++ b/modules/markup/markdown/transform_codespan.go @@ -0,0 +1,56 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "fmt" + "strings" + + "code.gitea.io/gitea/modules/markup" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// 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 (g *ASTTransformer) transformCodeSpan(_ *markup.RenderContext, v *ast.CodeSpan, reader text.Reader) { + colorContent := v.Text(reader.Source()) + if matchColor(strings.ToLower(string(colorContent))) { + v.AppendChild(v, NewColorPreview(colorContent)) + } +} diff --git a/modules/markup/markdown/transform_heading.go b/modules/markup/markdown/transform_heading.go new file mode 100644 index 0000000..6d48f34 --- /dev/null +++ b/modules/markup/markdown/transform_heading.go @@ -0,0 +1,32 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "fmt" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/util" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]markup.Header) { + for _, attr := range v.Attributes() { + if _, ok := attr.Value.([]byte); !ok { + v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value))) + } + } + txt := v.Text(reader.Source()) + header := markup.Header{ + Text: util.UnsafeBytesToString(txt), + Level: v.Level, + } + if id, found := v.AttributeString("id"); found { + header.ID = util.UnsafeBytesToString(id.([]byte)) + } + *tocList = append(*tocList, header) + g.applyElementDir(v) +} diff --git a/modules/markup/markdown/transform_image.go b/modules/markup/markdown/transform_image.go new file mode 100644 index 0000000..b34a710 --- /dev/null +++ b/modules/markup/markdown/transform_image.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "strings" + + "code.gitea.io/gitea/modules/markup" + giteautil "code.gitea.io/gitea/modules/util" + + "github.com/yuin/goldmark/ast" +) + +func (g *ASTTransformer) transformImage(ctx *markup.RenderContext, v *ast.Image) { + // Images need two things: + // + // 1. Their src needs to munged to be a real value + // 2. If they're not wrapped with a link they need a link wrapper + + // Check if the destination is a real link + if len(v.Destination) > 0 && !markup.IsLink(v.Destination) { + v.Destination = []byte(giteautil.URLJoin( + ctx.Links.ResolveMediaLink(ctx.IsWiki), + strings.TrimLeft(string(v.Destination), "/"), + )) + } + + parent := v.Parent() + // Create a link around image only if parent is not already a link + if _, ok := parent.(*ast.Link); !ok && parent != nil { + next := v.NextSibling() + + // Create a link wrapper + wrap := ast.NewLink() + wrap.Destination = v.Destination + wrap.Title = v.Title + wrap.SetAttributeString("target", []byte("_blank")) + + // Duplicate the current image node + image := ast.NewImage(ast.NewLink()) + image.Destination = v.Destination + image.Title = v.Title + for _, attr := range v.Attributes() { + image.SetAttribute(attr.Name, attr.Value) + } + for child := v.FirstChild(); child != nil; { + next := child.NextSibling() + image.AppendChild(image, child) + child = next + } + + // Append our duplicate image to the wrapper link + wrap.AppendChild(wrap, image) + + // Wire in the next sibling + wrap.SetNextSibling(next) + + // Replace the current node with the wrapper link + parent.ReplaceChild(parent, v, wrap) + + // But most importantly ensure the next sibling is still on the old image too + v.SetNextSibling(next) + } +} diff --git a/modules/markup/markdown/transform_link.go b/modules/markup/markdown/transform_link.go new file mode 100644 index 0000000..e6f3836 --- /dev/null +++ b/modules/markup/markdown/transform_link.go @@ -0,0 +1,46 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "slices" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + giteautil "code.gitea.io/gitea/modules/util" + + "github.com/yuin/goldmark/ast" +) + +func (g *ASTTransformer) transformLink(ctx *markup.RenderContext, v *ast.Link) { + // Links need their href to munged to be a real value + link := v.Destination + + // Do not process the link if it's not a link, starts with an hashtag + // (indicating it's an anchor link), starts with `mailto:` or any of the + // custom markdown URLs. + processLink := len(link) > 0 && !markup.IsLink(link) && + link[0] != '#' && !bytes.HasPrefix(link, byteMailto) && + !slices.ContainsFunc(setting.Markdown.CustomURLSchemes, func(s string) bool { + return bytes.HasPrefix(link, []byte(s+":")) + }) + + if processLink { + var base string + if ctx.IsWiki { + base = ctx.Links.WikiLink() + } else if ctx.Links.HasBranchInfo() { + base = ctx.Links.SrcLink() + } else { + base = ctx.Links.Base + } + + link = []byte(giteautil.URLJoin(base, string(link))) + } + if len(link) > 0 && link[0] == '#' { + link = []byte("#user-content-" + string(link)[1:]) + } + v.Destination = link +} diff --git a/modules/markup/markdown/transform_list.go b/modules/markup/markdown/transform_list.go new file mode 100644 index 0000000..b982fd4 --- /dev/null +++ b/modules/markup/markdown/transform_list.go @@ -0,0 +1,85 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "fmt" + + "code.gitea.io/gitea/modules/markup" + + "github.com/yuin/goldmark/ast" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/renderer/html" + "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) + 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') + } + } + } else { + _, _ = w.WriteString("</li>\n") + } + 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() + } + v.RemoveChildren(v) + + for _, child := range children { + listItem := child.(*ast.ListItem) + 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("class", []byte("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) + } + } + g.applyElementDir(v) +} |