Refactor render system (#32492)

There were too many patches to the Render system, it's really difficult
to make further improvements.

This PR clears the legacy problems and fix TODOs.

1. Rename `RenderContext.Type` to `RenderContext.MarkupType` to clarify
its usage.
2. Use `ContentMode` to replace `meta["mode"]` and `IsWiki`, to clarify
the rendering behaviors.
3. Use "wiki" mode instead of "mode=gfm + wiki=true"
4. Merge `renderByType` and `renderByFile`
5. Add more comments

----

The problem of "mode=document": in many cases it is not set, so many
non-comment places use comment's hard line break incorrectly
pull/32486/head^2
wxiaoguang 1 week ago committed by GitHub
parent 985e2a8af3
commit 3f9c3e7bc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      models/repo/repo.go
  2. 23
      modules/markup/console/console.go
  3. 9
      modules/markup/html.go
  4. 5
      modules/markup/html_codepreview_test.go
  5. 22
      modules/markup/html_internal_test.go
  6. 5
      modules/markup/html_issue.go
  7. 4
      modules/markup/html_link.go
  8. 4
      modules/markup/html_node.go
  9. 52
      modules/markup/html_test.go
  10. 7
      modules/markup/markdown/goldmark.go
  11. 4
      modules/markup/markdown/markdown.go
  12. 40
      modules/markup/markdown/markdown_test.go
  13. 2
      modules/markup/markdown/transform_image.go
  14. 5
      modules/markup/orgmode/orgmode.go
  15. 3
      modules/markup/orgmode/orgmode_test.go
  16. 108
      modules/markup/render.go
  17. 10
      modules/structs/miscellaneous.go
  18. 10
      modules/templates/util_render.go
  19. 15
      modules/templates/util_render_test.go
  20. 12
      routers/api/v1/misc/markup.go
  21. 13
      routers/api/v1/misc/markup_test.go
  22. 70
      routers/common/markup.go
  23. 1
      routers/web/feed/convert.go
  24. 4
      routers/web/misc/markup.go
  25. 12
      routers/web/repo/view.go
  26. 6
      routers/web/repo/wiki.go
  27. 1
      routers/web/user/profile.go
  28. 3
      services/context/org.go
  29. 8
      templates/swagger/v1_json.tmpl
  30. 27
      tests/integration/markup_external_test.go
  31. 3
      web_src/js/features/comp/ComboMarkdownEditor.ts
  32. 4
      web_src/js/features/repo-wiki.ts

@ -479,7 +479,6 @@ func (repo *Repository) ComposeMetas(ctx context.Context) map[string]string {
metas := map[string]string{ metas := map[string]string{
"user": repo.OwnerName, "user": repo.OwnerName,
"repo": repo.Name, "repo": repo.Name,
"mode": "comment",
} }
unit, err := repo.GetUnit(ctx, unit.TypeExternalTracker) unit, err := repo.GetUnit(ctx, unit.TypeExternalTracker)
@ -521,7 +520,6 @@ func (repo *Repository) ComposeDocumentMetas(ctx context.Context) map[string]str
for k, v := range repo.ComposeMetas(ctx) { for k, v := range repo.ComposeMetas(ctx) {
metas[k] = v metas[k] = v
} }
metas["mode"] = "document"
repo.DocumentRenderingMetas = metas repo.DocumentRenderingMetas = metas
} }
return repo.DocumentRenderingMetas return repo.DocumentRenderingMetas

@ -8,7 +8,6 @@ import (
"io" "io"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -17,9 +16,6 @@ import (
"github.com/go-enry/go-enry/v2" "github.com/go-enry/go-enry/v2"
) )
// MarkupName describes markup's name
var MarkupName = "console"
func init() { func init() {
markup.RegisterRenderer(Renderer{}) markup.RegisterRenderer(Renderer{})
} }
@ -29,7 +25,7 @@ type Renderer struct{}
// Name implements markup.Renderer // Name implements markup.Renderer
func (Renderer) Name() string { func (Renderer) Name() string {
return MarkupName return "console"
} }
// Extensions implements markup.Renderer // Extensions implements markup.Renderer
@ -67,20 +63,3 @@ func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Wri
_, err = output.Write(buf) _, err = output.Write(buf)
return err return err
} }
// Render renders terminal colors 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 terminal colors in string to HTML with all specific handling stuff and return string
func RenderString(ctx *markup.RenderContext, content string) (string, error) {
var buf strings.Builder
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
return "", err
}
return buf.String(), nil
}

@ -442,12 +442,11 @@ func createLink(href, content, class string) *html.Node {
a := &html.Node{ a := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
Data: atom.A.String(), Data: atom.A.String(),
Attr: []html.Attribute{ Attr: []html.Attribute{{Key: "href", Val: href}},
{Key: "href", Val: href}, }
{Key: "data-markdown-generated-content"}, if !RenderBehaviorForTesting.DisableInternalAttributes {
}, a.Attr = append(a.Attr, html.Attribute{Key: "data-markdown-generated-content"})
} }
if class != "" { if class != "" {
a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class}) a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class})
} }

@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -23,8 +24,8 @@ func TestRenderCodePreview(t *testing.T) {
}) })
test := func(input, expected string) { test := func(input, expected string) {
buffer, err := markup.RenderString(&markup.RenderContext{ buffer, err := markup.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Type: "markdown", MarkupType: markdown.MarkupName,
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer)) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))

@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
testModule "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -123,8 +124,9 @@ func TestRender_IssueIndexPattern2(t *testing.T) {
} }
expectedNil := fmt.Sprintf(expectedFmt, links...) expectedNil := fmt.Sprintf(expectedFmt, links...)
testRenderIssueIndexPattern(t, s, expectedNil, &RenderContext{ testRenderIssueIndexPattern(t, s, expectedNil, &RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Metas: localMetas, Metas: localMetas,
ContentMode: RenderContentAsComment,
}) })
class := "ref-issue" class := "ref-issue"
@ -137,8 +139,9 @@ func TestRender_IssueIndexPattern2(t *testing.T) {
} }
expectedNum := fmt.Sprintf(expectedFmt, links...) expectedNum := fmt.Sprintf(expectedFmt, links...)
testRenderIssueIndexPattern(t, s, expectedNum, &RenderContext{ testRenderIssueIndexPattern(t, s, expectedNum, &RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Metas: numericMetas, Metas: numericMetas,
ContentMode: RenderContentAsComment,
}) })
} }
@ -266,7 +269,6 @@ func TestRender_IssueIndexPattern_Document(t *testing.T) {
"user": "someUser", "user": "someUser",
"repo": "someRepo", "repo": "someRepo",
"style": IssueNameStyleNumeric, "style": IssueNameStyleNumeric,
"mode": "document",
} }
testRenderIssueIndexPattern(t, "#1", "#1", &RenderContext{ testRenderIssueIndexPattern(t, "#1", "#1", &RenderContext{
@ -316,8 +318,8 @@ func TestRender_AutoLink(t *testing.T) {
Links: Links{ Links: Links{
Base: TestRepoURL, Base: TestRepoURL,
}, },
Metas: localMetas, Metas: localMetas,
IsWiki: true, ContentMode: RenderContentAsWiki,
}, strings.NewReader(input), &buffer) }, strings.NewReader(input), &buffer)
assert.Equal(t, err, nil) assert.Equal(t, err, nil)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String())) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String()))
@ -340,7 +342,7 @@ func TestRender_AutoLink(t *testing.T) {
func TestRender_FullIssueURLs(t *testing.T) { func TestRender_FullIssueURLs(t *testing.T) {
setting.AppURL = TestAppURL setting.AppURL = TestAppURL
defer testModule.MockVariableValue(&RenderBehaviorForTesting.DisableInternalAttributes, true)()
test := func(input, expected string) { test := func(input, expected string) {
var result strings.Builder var result strings.Builder
err := postProcess(&RenderContext{ err := postProcess(&RenderContext{
@ -351,9 +353,7 @@ func TestRender_FullIssueURLs(t *testing.T) {
Metas: localMetas, Metas: localMetas,
}, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result) }, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result)
assert.NoError(t, err) assert.NoError(t, err)
actual := result.String() assert.Equal(t, expected, result.String())
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
assert.Equal(t, expected, actual)
} }
test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6", test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
"Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6") "Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")

@ -67,9 +67,8 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
return return
} }
// FIXME: the use of "mode" is quite dirty and hacky, for example: what is a "document"? how should it be rendered? // crossLinkOnly if not comment and not wiki
// The "mode" approach should be refactored to some other more clear&reliable way. crossLinkOnly := ctx.ContentMode != RenderContentAsTitle && ctx.ContentMode != RenderContentAsComment && ctx.ContentMode != RenderContentAsWiki
crossLinkOnly := ctx.Metas["mode"] == "document" && !ctx.IsWiki
var ( var (
found bool found bool

@ -20,7 +20,7 @@ func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (resu
isAnchorFragment := link != "" && link[0] == '#' isAnchorFragment := link != "" && link[0] == '#'
if !isAnchorFragment && !IsFullURLString(link) { if !isAnchorFragment && !IsFullURLString(link) {
linkBase := ctx.Links.Base linkBase := ctx.Links.Base
if ctx.IsWiki { if ctx.ContentMode == RenderContentAsWiki {
// no need to check if the link should be resolved as a wiki link or a wiki raw link // no need to check if the link should be resolved as a wiki link or a wiki raw link
// just use wiki link here and it will be redirected to a wiki raw link if necessary // just use wiki link here and it will be redirected to a wiki raw link if necessary
linkBase = ctx.Links.WikiLink() linkBase = ctx.Links.WikiLink()
@ -147,7 +147,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
} }
if image { if image {
if !absoluteLink { if !absoluteLink {
link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), link) link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.ContentMode == RenderContentAsWiki), link)
} }
title := props["title"] title := props["title"]
if title == "" { if title == "" {

@ -17,7 +17,7 @@ func visitNodeImg(ctx *RenderContext, img *html.Node) (next *html.Node) {
} }
if IsNonEmptyRelativePath(attr.Val) { if IsNonEmptyRelativePath(attr.Val) {
attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val) attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.ContentMode == RenderContentAsWiki), attr.Val)
// By default, the "<img>" tag should also be clickable, // By default, the "<img>" tag should also be clickable,
// because frontend use `<img>` to paste the re-scaled image into the markdown, // because frontend use `<img>` to paste the re-scaled image into the markdown,
@ -53,7 +53,7 @@ func visitNodeVideo(ctx *RenderContext, node *html.Node) (next *html.Node) {
continue continue
} }
if IsNonEmptyRelativePath(attr.Val) { if IsNonEmptyRelativePath(attr.Val) {
attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), attr.Val) attr.Val = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.ContentMode == RenderContentAsWiki), attr.Val)
} }
attr.Val = camoHandleLink(attr.Val) attr.Val = camoHandleLink(attr.Val)
node.Attr[i] = attr node.Attr[i] = attr

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
testModule "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -104,7 +105,7 @@ func TestRender_Commits(t *testing.T) {
func TestRender_CrossReferences(t *testing.T) { func TestRender_CrossReferences(t *testing.T) {
setting.AppURL = markup.TestAppURL setting.AppURL = markup.TestAppURL
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
test := func(input, expected string) { test := func(input, expected string) {
buffer, err := markup.RenderString(&markup.RenderContext{ buffer, err := markup.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
@ -116,9 +117,7 @@ func TestRender_CrossReferences(t *testing.T) {
Metas: localMetas, Metas: localMetas,
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
actual := strings.TrimSpace(buffer) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
assert.Equal(t, strings.TrimSpace(expected), actual)
} }
test( test(
@ -148,7 +147,7 @@ func TestRender_CrossReferences(t *testing.T) {
func TestRender_links(t *testing.T) { func TestRender_links(t *testing.T) {
setting.AppURL = markup.TestAppURL setting.AppURL = markup.TestAppURL
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
test := func(input, expected string) { test := func(input, expected string) {
buffer, err := markup.RenderString(&markup.RenderContext{ buffer, err := markup.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
@ -158,9 +157,7 @@ func TestRender_links(t *testing.T) {
}, },
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
actual := strings.TrimSpace(buffer) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
assert.Equal(t, strings.TrimSpace(expected), actual)
} }
oldCustomURLSchemes := setting.Markdown.CustomURLSchemes oldCustomURLSchemes := setting.Markdown.CustomURLSchemes
@ -261,7 +258,7 @@ func TestRender_links(t *testing.T) {
func TestRender_email(t *testing.T) { func TestRender_email(t *testing.T) {
setting.AppURL = markup.TestAppURL setting.AppURL = markup.TestAppURL
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
test := func(input, expected string) { test := func(input, expected string) {
res, err := markup.RenderString(&markup.RenderContext{ res, err := markup.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
@ -271,9 +268,7 @@ func TestRender_email(t *testing.T) {
}, },
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
actual := strings.TrimSpace(res) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res))
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
assert.Equal(t, strings.TrimSpace(expected), actual)
} }
// Text that should be turned into email link // Text that should be turned into email link
@ -302,10 +297,10 @@ func TestRender_email(t *testing.T) {
j.doe@example.com; j.doe@example.com;
j.doe@example.com? j.doe@example.com?
j.doe@example.com!`, j.doe@example.com!`,
`<p><a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>,<br/> `<p><a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>,
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>.<br/> <a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>.
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>;<br/> <a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>;
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>?<br/> <a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>?
<a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>!</p>`) <a href="mailto:j.doe@example.com" rel="nofollow">j.doe@example.com</a>!</p>`)
// Test that should *not* be turned into email links // Test that should *not* be turned into email links
@ -418,8 +413,8 @@ func TestRender_ShortLinks(t *testing.T) {
Links: markup.Links{ Links: markup.Links{
Base: markup.TestRepoURL, Base: markup.TestRepoURL,
}, },
Metas: localMetas, Metas: localMetas,
IsWiki: true, ContentMode: markup.RenderContentAsWiki,
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer))) assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
@ -531,10 +526,10 @@ func TestRender_ShortLinks(t *testing.T) {
func TestRender_RelativeMedias(t *testing.T) { func TestRender_RelativeMedias(t *testing.T) {
render := func(input string, isWiki bool, links markup.Links) string { render := func(input string, isWiki bool, links markup.Links) string {
buffer, err := markdown.RenderString(&markup.RenderContext{ buffer, err := markdown.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Links: links, Links: links,
Metas: localMetas, Metas: localMetas,
IsWiki: isWiki, ContentMode: util.Iif(isWiki, markup.RenderContentAsWiki, markup.RenderContentAsComment),
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
return strings.TrimSpace(string(buffer)) return strings.TrimSpace(string(buffer))
@ -604,12 +599,7 @@ func Test_ParseClusterFuzz(t *testing.T) {
func TestPostProcess_RenderDocument(t *testing.T) { func TestPostProcess_RenderDocument(t *testing.T) {
setting.AppURL = markup.TestAppURL setting.AppURL = markup.TestAppURL
setting.StaticURLPrefix = markup.TestAppURL // can't run standalone setting.StaticURLPrefix = markup.TestAppURL // can't run standalone
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
localMetas := map[string]string{
"user": "go-gitea",
"repo": "gitea",
"mode": "document",
}
test := func(input, expected string) { test := func(input, expected string) {
var res strings.Builder var res strings.Builder
@ -619,12 +609,10 @@ func TestPostProcess_RenderDocument(t *testing.T) {
AbsolutePrefix: true, AbsolutePrefix: true,
Base: "https://example.com", Base: "https://example.com",
}, },
Metas: localMetas, Metas: map[string]string{"user": "go-gitea", "repo": "gitea"},
}, strings.NewReader(input), &res) }, strings.NewReader(input), &res)
assert.NoError(t, err) assert.NoError(t, err)
actual := strings.TrimSpace(res.String()) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res.String()))
actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
assert.Equal(t, strings.TrimSpace(expected), actual)
} }
// Issue index shouldn't be post processing in a document. // Issue index shouldn't be post processing in a document.

@ -72,7 +72,12 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
g.transformList(ctx, v, rc) g.transformList(ctx, v, rc)
case *ast.Text: case *ast.Text:
if v.SoftLineBreak() && !v.HardLineBreak() { if v.SoftLineBreak() && !v.HardLineBreak() {
if ctx.Metas["mode"] != "document" { // TODO: this was a quite unclear part, old code: `if metas["mode"] != "document" { use comment link break setting }`
// many places render non-comment contents with no mode=document, then these contents also use comment's hard line break setting
// especially in many tests.
if markup.RenderBehaviorForTesting.ForceHardLineBreak {
v.SetHardLineBreak(true)
} else if ctx.ContentMode == markup.RenderContentAsComment {
v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments) v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments)
} else { } else {
v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments) v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments)

@ -257,9 +257,7 @@ func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Wri
// Render renders Markdown to HTML with all specific handling stuff. // Render renders Markdown to HTML with all specific handling stuff.
func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
if ctx.Type == "" { ctx.MarkupType = MarkupName
ctx.Type = MarkupName
}
return markup.Render(ctx, input, output) return markup.Render(ctx, input, output)
} }

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -74,7 +75,7 @@ func TestRender_StandardLinks(t *testing.T) {
Links: markup.Links{ Links: markup.Links{
Base: FullURL, Base: FullURL,
}, },
IsWiki: true, ContentMode: markup.RenderContentAsWiki,
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer))) assert.Equal(t, strings.TrimSpace(expectedWiki), strings.TrimSpace(string(buffer)))
@ -296,23 +297,22 @@ This PR has been generated by [Renovate Bot](https://github.com/renovatebot/reno
} }
func TestTotal_RenderWiki(t *testing.T) { func TestTotal_RenderWiki(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.ForceHardLineBreak, true)()
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
setting.AppURL = AppURL setting.AppURL = AppURL
answers := testAnswers(util.URLJoin(FullURL, "wiki"), util.URLJoin(FullURL, "wiki", "raw")) answers := testAnswers(util.URLJoin(FullURL, "wiki"), util.URLJoin(FullURL, "wiki", "raw"))
for i := 0; i < len(sameCases); i++ { for i := 0; i < len(sameCases); i++ {
line, err := markdown.RenderString(&markup.RenderContext{ line, err := markdown.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
Links: markup.Links{ Links: markup.Links{
Base: FullURL, Base: FullURL,
}, },
Repo: newMockRepo(testRepoOwnerName, testRepoName), Repo: newMockRepo(testRepoOwnerName, testRepoName),
Metas: localMetas, Metas: localMetas,
IsWiki: true, ContentMode: markup.RenderContentAsWiki,
}, sameCases[i]) }, sameCases[i])
assert.NoError(t, err) assert.NoError(t, err)
actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "") assert.Equal(t, answers[i], string(line))
assert.Equal(t, answers[i], actual)
} }
testCases := []string{ testCases := []string{
@ -334,19 +334,18 @@ func TestTotal_RenderWiki(t *testing.T) {
Links: markup.Links{ Links: markup.Links{
Base: FullURL, Base: FullURL,
}, },
IsWiki: true, ContentMode: markup.RenderContentAsWiki,
}, testCases[i]) }, testCases[i])
assert.NoError(t, err) assert.NoError(t, err)
actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "") assert.EqualValues(t, testCases[i+1], string(line))
assert.EqualValues(t, testCases[i+1], actual)
} }
} }
func TestTotal_RenderString(t *testing.T) { func TestTotal_RenderString(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.ForceHardLineBreak, true)()
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
setting.AppURL = AppURL setting.AppURL = AppURL
answers := testAnswers(util.URLJoin(FullURL, "src", "master"), util.URLJoin(FullURL, "media", "master")) answers := testAnswers(util.URLJoin(FullURL, "src", "master"), util.URLJoin(FullURL, "media", "master"))
for i := 0; i < len(sameCases); i++ { for i := 0; i < len(sameCases); i++ {
line, err := markdown.RenderString(&markup.RenderContext{ line, err := markdown.RenderString(&markup.RenderContext{
Ctx: git.DefaultContext, Ctx: git.DefaultContext,
@ -358,8 +357,7 @@ func TestTotal_RenderString(t *testing.T) {
Metas: localMetas, Metas: localMetas,
}, sameCases[i]) }, sameCases[i])
assert.NoError(t, err) assert.NoError(t, err)
actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "") assert.Equal(t, answers[i], string(line))
assert.Equal(t, answers[i], actual)
} }
testCases := []string{} testCases := []string{}
@ -428,6 +426,7 @@ func TestRenderSiblingImages_Issue12925(t *testing.T) {
expected := `<p><a href="/image1" target="_blank" rel="nofollow noopener"><img src="/image1" alt="image1"></a><br> 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> <a href="/image2" target="_blank" rel="nofollow noopener"><img src="/image2" alt="image2"></a></p>
` `
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.ForceHardLineBreak, true)()
res, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, testcase) res, err := markdown.RenderRawString(&markup.RenderContext{Ctx: git.DefaultContext}, testcase)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, expected, res) assert.Equal(t, expected, res)
@ -996,11 +995,16 @@ space</p>
}, },
} }
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.ForceHardLineBreak, true)()
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
for i, c := range cases { for i, c := range cases {
result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background(), Links: c.Links, IsWiki: c.IsWiki}, input) result, err := markdown.RenderString(&markup.RenderContext{
Ctx: context.Background(),
Links: c.Links,
ContentMode: util.Iif(c.IsWiki, markup.RenderContentAsWiki, markup.RenderContentAsDefault),
}, input)
assert.NoError(t, err, "Unexpected error in testcase: %v", i) assert.NoError(t, err, "Unexpected error in testcase: %v", i)
actual := strings.ReplaceAll(string(result), ` data-markdown-generated-content=""`, "") assert.Equal(t, c.Expected, string(result), "Unexpected result in testcase %v", i)
assert.Equal(t, c.Expected, actual, "Unexpected result in testcase %v", i)
} }
} }

@ -21,7 +21,7 @@ func (g *ASTTransformer) transformImage(ctx *markup.RenderContext, v *ast.Image)
// Check if the destination is a real link // Check if the destination is a real link
if len(v.Destination) > 0 && !markup.IsFullURLBytes(v.Destination) { if len(v.Destination) > 0 && !markup.IsFullURLBytes(v.Destination) {
v.Destination = []byte(giteautil.URLJoin( v.Destination = []byte(giteautil.URLJoin(
ctx.Links.ResolveMediaLink(ctx.IsWiki), ctx.Links.ResolveMediaLink(ctx.ContentMode == markup.RenderContentAsWiki),
strings.TrimLeft(string(v.Destination), "/"), strings.TrimLeft(string(v.Destination), "/"),
)) ))
} }

@ -144,14 +144,15 @@ func (r *Writer) resolveLink(kind, link string) string {
} }
base := r.Ctx.Links.Base base := r.Ctx.Links.Base
if r.Ctx.IsWiki { isWiki := r.Ctx.ContentMode == markup.RenderContentAsWiki
if isWiki {
base = r.Ctx.Links.WikiLink() base = r.Ctx.Links.WikiLink()
} else if r.Ctx.Links.HasBranchInfo() { } else if r.Ctx.Links.HasBranchInfo() {
base = r.Ctx.Links.SrcLink() base = r.Ctx.Links.SrcLink()
} }
if kind == "image" || kind == "video" { if kind == "image" || kind == "video" {
base = r.Ctx.Links.ResolveMediaLink(r.Ctx.IsWiki) base = r.Ctx.Links.ResolveMediaLink(isWiki)
} }
link = util.URLJoin(base, link) link = util.URLJoin(base, link)

@ -10,6 +10,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -26,7 +27,7 @@ func TestRender_StandardLinks(t *testing.T) {
Base: "/relative-path", Base: "/relative-path",
BranchPath: "branch/main", BranchPath: "branch/main",
}, },
IsWiki: isWiki, ContentMode: util.Iif(isWiki, markup.RenderContentAsWiki, markup.RenderContentAsDefault),
}, input) }, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer)) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))

@ -5,11 +5,9 @@ package markup
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
"path/filepath"
"strings" "strings"
"sync" "sync"
@ -29,15 +27,44 @@ const (
RenderMetaAsTable RenderMetaMode = "table" RenderMetaAsTable RenderMetaMode = "table"
) )
type RenderContentMode string
const (
RenderContentAsDefault RenderContentMode = "" // empty means "default", no special handling, maybe just a simple "document"
RenderContentAsComment RenderContentMode = "comment"
RenderContentAsTitle RenderContentMode = "title"
RenderContentAsWiki RenderContentMode = "wiki"
)
var RenderBehaviorForTesting struct {
// Markdown line break rendering has 2 default behaviors:
// * Use hard: replace "\n" with "<br>" for comments, setting.Markdown.EnableHardLineBreakInComments=true
// * Keep soft: "\n" for non-comments (a.k.a. documents), setting.Markdown.EnableHardLineBreakInDocuments=false
// In history, there was a mess:
// * The behavior was controlled by `Metas["mode"] != "document",
// * However, many places render the content without setting "mode" in Metas, all these places used comment line break setting incorrectly
ForceHardLineBreak bool
// Gitea will emit some internal attributes for various purposes, these attributes don't affect rendering.
// But there are too many hard-coded test cases, to avoid changing all of them again and again, we can disable emitting these internal attributes.
DisableInternalAttributes bool
}
// RenderContext represents a render context // RenderContext represents a render context
type RenderContext struct { type RenderContext struct {
Ctx context.Context Ctx context.Context
RelativePath string // relative path from tree root of the branch RelativePath string // relative path from tree root of the branch
Type string
IsWiki bool // eg: "orgmode", "asciicast", "console"
Links Links // for file mode, it could be left as empty, and will be detected by file extension in RelativePath
Metas map[string]string // user, repo, mode(comment/document) MarkupType string
DefaultLink string
// what the content will be used for: eg: for comment or for wiki? or just render a file?
ContentMode RenderContentMode
Links Links // special link references for rendering, especially when there is a branch/tree path
Metas map[string]string // user&repo, format&style&regexp (for external issue pattern), teams&org (for mention), BranchNameSubURL(for iframe&asciicast)
DefaultLink string // TODO: need to figure out
GitRepo *git.Repository GitRepo *git.Repository
Repo gitrepo.Repository Repo gitrepo.Repository
ShaExistCache map[string]bool ShaExistCache map[string]bool
@ -77,12 +104,29 @@ func (ctx *RenderContext) AddCancel(fn func()) {
// Render renders markup file to HTML with all specific handling stuff. // Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error { func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
if ctx.Type != "" { if ctx.MarkupType == "" && ctx.RelativePath != "" {
return renderByType(ctx, input, output) ctx.MarkupType = DetectMarkupTypeByFileName(ctx.RelativePath)
} else if ctx.RelativePath != "" { if ctx.MarkupType == "" {
return renderFile(ctx, input, output) return util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RelativePath)
}
}
renderer := renderers[ctx.MarkupType]
if renderer == nil {
return util.NewInvalidArgumentErrorf("unsupported markup type: %q", ctx.MarkupType)
}
if ctx.RelativePath != "" {
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
if !ctx.InStandalonePage {
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
}
} }
return errors.New("render options both filename and type missing")
return render(ctx, renderer, input, output)
} }
// RenderString renders Markup string to HTML with all specific handling stuff and return string // RenderString renders Markup string to HTML with all specific handling stuff and return string
@ -170,42 +214,6 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr
return err return err
} }
func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error {
if renderer, ok := renderers[ctx.Type]; ok {
return render(ctx, renderer, input, output)
}
return fmt.Errorf("unsupported render type: %s", ctx.Type)
}
// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render
type ErrUnsupportedRenderExtension struct {
Extension string
}
func IsErrUnsupportedRenderExtension(err error) bool {
_, ok := err.(ErrUnsupportedRenderExtension)
return ok
}
func (err ErrUnsupportedRenderExtension) Error() string {
return fmt.Sprintf("Unsupported render extension: %s", err.Extension)
}
func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error {
extension := strings.ToLower(filepath.Ext(ctx.RelativePath))
if renderer, ok := extRenderers[extension]; ok {
if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
if !ctx.InStandalonePage {
// for an external render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
}
return render(ctx, renderer, input, output)
}
return ErrUnsupportedRenderExtension{extension}
}
// Init initializes the render global variables // Init initializes the render global variables
func Init(ph *ProcessorHelper) { func Init(ph *ProcessorHelper) {
if ph != nil { if ph != nil {

@ -21,7 +21,7 @@ type MarkupOption struct {
// //
// in: body // in: body
Text string Text string
// Mode to render (comment, gfm, markdown, file) // Mode to render (markdown, comment, wiki, file)
// //
// in: body // in: body
Mode string Mode string
@ -30,8 +30,9 @@ type MarkupOption struct {
// //
// in: body // in: body
Context string Context string
// Is it a wiki page ? // Is it a wiki page? (use mode=wiki instead)
// //
// Deprecated: true
// in: body // in: body
Wiki bool Wiki bool
// File path for detecting extension in file mode // File path for detecting extension in file mode
@ -50,7 +51,7 @@ type MarkdownOption struct {
// //
// in: body // in: body
Text string Text string
// Mode to render (comment, gfm, markdown) // Mode to render (markdown, comment, wiki, file)
// //
// in: body // in: body
Mode string Mode string
@ -59,8 +60,9 @@ type MarkdownOption struct {
// //
// in: body // in: body
Context string Context string
// Is it a wiki page ? // Is it a wiki page? (use mode=wiki instead)
// //
// Deprecated: true
// in: body // in: body
Wiki bool Wiki bool
} }

@ -94,8 +94,9 @@ func (ut *RenderUtils) RenderCommitBody(msg string, metas map[string]string) tem
} }
renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{
Ctx: ut.ctx, Ctx: ut.ctx,
Metas: metas, Metas: metas,
ContentMode: markup.RenderContentAsComment,
}, template.HTMLEscapeString(msgLine)) }, template.HTMLEscapeString(msgLine))
if err != nil { if err != nil {
log.Error("RenderCommitMessage: %v", err) log.Error("RenderCommitMessage: %v", err)
@ -116,8 +117,9 @@ func renderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
// RenderIssueTitle renders issue/pull title with defined post processors // RenderIssueTitle renders issue/pull title with defined post processors
func (ut *RenderUtils) RenderIssueTitle(text string, metas map[string]string) template.HTML { func (ut *RenderUtils) RenderIssueTitle(text string, metas map[string]string) template.HTML {
renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{
Ctx: ut.ctx, Ctx: ut.ctx,
Metas: metas, ContentMode: markup.RenderContentAsTitle,
Metas: metas,
}, template.HTMLEscapeString(text)) }, template.HTMLEscapeString(text))
if err != nil { if err != nil {
log.Error("RenderIssueTitle: %v", err) log.Error("RenderIssueTitle: %v", err)

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -72,6 +73,7 @@ func newTestRenderUtils() *RenderUtils {
} }
func TestRenderCommitBody(t *testing.T) { func TestRenderCommitBody(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
type args struct { type args struct {
msg string msg string
metas map[string]string metas map[string]string
@ -129,23 +131,21 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<a href="/mention-user" class="mention">@mention-user</a> test <a href="/mention-user" class="mention">@mention-user</a> test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space` space`
actual := strings.ReplaceAll(string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)), ` data-markdown-generated-content=""`, "") assert.EqualValues(t, expected, string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)))
assert.EqualValues(t, expected, actual)
} }
func TestRenderCommitMessage(t *testing.T) { func TestRenderCommitMessage(t *testing.T) {
expected := `space <a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a> ` expected := `space <a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a> `
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas)) assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas))
} }
func TestRenderCommitMessageLinkSubject(t *testing.T) { func TestRenderCommitMessageLinkSubject(t *testing.T) {
expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a>` expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a>`
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas)) assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
} }
func TestRenderIssueTitle(t *testing.T) { func TestRenderIssueTitle(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
expected := ` space @mention-user<SPACE><SPACE> expected := ` space @mention-user<SPACE><SPACE>
/just/a/path.bin /just/a/path.bin
https://example.com/file.bin https://example.com/file.bin
@ -168,11 +168,11 @@ mail@domain.com
space<SPACE><SPACE> space<SPACE><SPACE>
` `
expected = strings.ReplaceAll(expected, "<SPACE>", " ") expected = strings.ReplaceAll(expected, "<SPACE>", " ")
actual := strings.ReplaceAll(string(newTestRenderUtils().RenderIssueTitle(testInput(), testMetas)), ` data-markdown-generated-content=""`, "") assert.EqualValues(t, expected, string(newTestRenderUtils().RenderIssueTitle(testInput(), testMetas)))
assert.EqualValues(t, expected, actual)
} }
func TestRenderMarkdownToHtml(t *testing.T) { func TestRenderMarkdownToHtml(t *testing.T) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/> expected := `<p>space <a href="/mention-user" rel="nofollow">@mention-user</a><br/>
/just/a/path.bin /just/a/path.bin
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a> <a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
@ -194,8 +194,7 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
#123 #123
space</p> space</p>
` `
actual := strings.ReplaceAll(string(newTestRenderUtils().MarkdownToHtml(testInput())), ` data-markdown-generated-content=""`, "") assert.Equal(t, expected, string(newTestRenderUtils().MarkdownToHtml(testInput())))
assert.Equal(t, expected, actual)
} }
func TestRenderLabels(t *testing.T) { func TestRenderLabels(t *testing.T) {

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
@ -41,7 +42,8 @@ func Markup(ctx *context.APIContext) {
return return
} }
common.RenderMarkup(ctx.Base, ctx.Repo, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck
common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, form.FilePath)
} }
// Markdown render markdown document to HTML // Markdown render markdown document to HTML
@ -71,12 +73,8 @@ func Markdown(ctx *context.APIContext) {
return return
} }
mode := "markdown" mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck
if form.Mode == "comment" || form.Mode == "gfm" { common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, "")
mode = form.Mode
}
common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, "", form.Wiki)
} }
// MarkdownRaw render raw markdown HTML // MarkdownRaw render raw markdown HTML

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
@ -24,6 +25,7 @@ const AppURL = "http://localhost:3000/"
func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) { func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) {
setting.AppURL = AppURL setting.AppURL = AppURL
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
context := "/gogits/gogs" context := "/gogits/gogs"
if !wiki { if !wiki {
context += path.Join("/src/branch/main", path.Dir(filePath)) context += path.Join("/src/branch/main", path.Dir(filePath))
@ -38,13 +40,13 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
web.SetForm(ctx, &options) web.SetForm(ctx, &options)
Markup(ctx) Markup(ctx)
actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "") assert.Equal(t, expectedBody, resp.Body.String())
assert.Equal(t, expectedBody, actual)
assert.Equal(t, expectedCode, resp.Code) assert.Equal(t, expectedCode, resp.Code)
resp.Body.Reset() resp.Body.Reset()
} }
func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) { func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) {
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableInternalAttributes, true)()
setting.AppURL = AppURL setting.AppURL = AppURL
context := "/gogits/gogs" context := "/gogits/gogs"
if !wiki { if !wiki {
@ -59,8 +61,7 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
web.SetForm(ctx, &options) web.SetForm(ctx, &options)
Markdown(ctx) Markdown(ctx)
actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "") assert.Equal(t, responseBody, resp.Body.String())
assert.Equal(t, responseBody, actual)
assert.Equal(t, responseCode, resp.Code) assert.Equal(t, responseCode, resp.Code)
resp.Body.Reset() resp.Body.Reset()
} }
@ -158,8 +159,8 @@ Here are some links to the most important topics. You can find the full list of
<a href="http://localhost:3000/gogits/gogs/media/branch/main/path/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/gogits/gogs/media/branch/main/path/image.png" alt="Image"/></a></p> <a href="http://localhost:3000/gogits/gogs/media/branch/main/path/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/gogits/gogs/media/branch/main/path/image.png" alt="Image"/></a></p>
`, http.StatusOK) `, http.StatusOK)
testRenderMarkup(t, "file", true, "path/test.unknown", "## Test", "Unsupported render extension: .unknown\n", http.StatusUnprocessableEntity) testRenderMarkup(t, "file", false, "path/test.unknown", "## Test", "unsupported file to render: \"path/test.unknown\"\n", http.StatusUnprocessableEntity)
testRenderMarkup(t, "unknown", true, "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity) testRenderMarkup(t, "unknown", false, "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity)
} }
var simpleCases = []string{ var simpleCases = []string{

@ -5,21 +5,22 @@
package common package common
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"path" "path"
"strings" "strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
) )
// RenderMarkup renders markup text for the /markup and /markdown endpoints // RenderMarkup renders markup text for the /markup and /markdown endpoints
func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPathContext, filePath string, wiki bool) { func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPathContext, filePath string) {
// urlPathContext format is "/subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}" // urlPathContext format is "/subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}"
// filePath is the path of the file to render if the end user is trying to preview a repo file (mode == "file") // filePath is the path of the file to render if the end user is trying to preview a repo file (mode == "file")
// filePath will be used as RenderContext.RelativePath // filePath will be used as RenderContext.RelativePath
@ -27,32 +28,33 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPa
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md" // for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc" // and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
var markupType, relativePath string renderCtx := &markup.RenderContext{
Ctx: ctx,
links := markup.Links{AbsolutePrefix: true} Links: markup.Links{AbsolutePrefix: true},
MarkupType: markdown.MarkupName,
}
if urlPathContext != "" { if urlPathContext != "" {
links.Base = fmt.Sprintf("%s%s", httplib.GuessCurrentHostURL(ctx), urlPathContext) renderCtx.Links.Base = fmt.Sprintf("%s%s", httplib.GuessCurrentHostURL(ctx), urlPathContext)
} }
switch mode { if mode == "" || mode == "markdown" {
case "markdown": // raw markdown doesn't need any special handling
// Raw markdown if err := markdown.RenderRaw(renderCtx, strings.NewReader(text), ctx.Resp); err != nil {
if err := markdown.RenderRaw(&markup.RenderContext{
Ctx: ctx,
Links: links,
}, strings.NewReader(text), ctx.Resp); err != nil {
ctx.Error(http.StatusInternalServerError, err.Error()) ctx.Error(http.StatusInternalServerError, err.Error())
} }
return return
}
switch mode {
case "gfm": // legacy mode, do nothing
case "comment": case "comment":
// Issue & comment content renderCtx.ContentMode = markup.RenderContentAsComment
markupType = markdown.MarkupName case "wiki":
case "gfm": renderCtx.ContentMode = markup.RenderContentAsWiki
// GitHub Flavored Markdown
markupType = markdown.MarkupName
case "file": case "file":
markupType = "" // render the repo file content by its extension // render the repo file content by its extension
relativePath = filePath renderCtx.MarkupType = ""
renderCtx.RelativePath = filePath
renderCtx.InStandalonePage = true
default: default:
ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Unknown mode: %s", mode)) ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Unknown mode: %s", mode))
return return
@ -67,33 +69,19 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPa
refPath := strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc" refPath := strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc"
refPath = strings.TrimSuffix(refPath, "/"+fileDir) // now we get the correct branch path: "branch/features/feat-12" refPath = strings.TrimSuffix(refPath, "/"+fileDir) // now we get the correct branch path: "branch/features/feat-12"
links = markup.Links{AbsolutePrefix: true, Base: absoluteBasePrefix, BranchPath: refPath, TreePath: fileDir} renderCtx.Links = markup.Links{AbsolutePrefix: true, Base: absoluteBasePrefix, BranchPath: refPath, TreePath: fileDir}
} }
meta := map[string]string{}
var repoCtx *repo_model.Repository
if repo != nil && repo.Repository != nil { if repo != nil && repo.Repository != nil {
repoCtx = repo.Repository renderCtx.Repo = repo.Repository
if mode == "comment" { if renderCtx.ContentMode == markup.RenderContentAsComment {
meta = repo.Repository.ComposeMetas(ctx) renderCtx.Metas = repo.Repository.ComposeMetas(ctx)
} else { } else {
meta = repo.Repository.ComposeDocumentMetas(ctx) renderCtx.Metas = repo.Repository.ComposeDocumentMetas(ctx)
} }
} }
if mode != "comment" { if err := markup.Render(renderCtx, strings.NewReader(text), ctx.Resp); err != nil {
meta["mode"] = "document" if errors.Is(err, util.ErrInvalidArgument) {
}
if err := markup.Render(&markup.RenderContext{
Ctx: ctx,
Repo: repoCtx,
Links: links,
Metas: meta,
IsWiki: wiki,
Type: markupType,
RelativePath: relativePath,
}, strings.NewReader(text), ctx.Resp); err != nil {
if markup.IsErrUnsupportedRenderExtension(err) {
ctx.Error(http.StatusUnprocessableEntity, err.Error()) ctx.Error(http.StatusUnprocessableEntity, err.Error())
} else { } else {
ctx.Error(http.StatusInternalServerError, err.Error()) ctx.Error(http.StatusInternalServerError, err.Error())

@ -56,7 +56,6 @@ func renderMarkdown(ctx *context.Context, act *activities_model.Action, content
Links: markup.Links{ Links: markup.Links{
Base: act.GetRepoLink(ctx), Base: act.GetRepoLink(ctx),
}, },
Type: markdown.MarkupName,
Metas: map[string]string{ Metas: map[string]string{
"user": act.GetRepoUserName(ctx), "user": act.GetRepoUserName(ctx),
"repo": act.GetRepoName(ctx), "repo": act.GetRepoName(ctx),

@ -6,6 +6,7 @@ package misc
import ( import (
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
@ -14,5 +15,6 @@ import (
// Markup render markup document to HTML // Markup render markup document to HTML
func Markup(ctx *context.Context) { func Markup(ctx *context.Context) {
form := web.GetForm(ctx).(*api.MarkupOption) form := web.GetForm(ctx).(*api.MarkupOption)
common.RenderMarkup(ctx.Base, ctx.Repo, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck
common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, form.FilePath)
} }

@ -312,6 +312,7 @@ func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.Tr
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{
Ctx: ctx, Ctx: ctx,
MarkupType: markupType,
RelativePath: path.Join(ctx.Repo.TreePath, readmeFile.Name()), // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). RelativePath: path.Join(ctx.Repo.TreePath, readmeFile.Name()), // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path).
Links: markup.Links{ Links: markup.Links{
Base: ctx.Repo.RepoLink, Base: ctx.Repo.RepoLink,
@ -502,28 +503,20 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["ReadmeExist"] = readmeExist ctx.Data["ReadmeExist"] = readmeExist
markupType := markup.DetectMarkupTypeByFileName(blob.Name()) markupType := markup.DetectMarkupTypeByFileName(blob.Name())
// If the markup is detected by custom markup renderer it should not be reset later on
// to not pass it down to the render context.
detected := false
if markupType == "" { if markupType == "" {
detected = true
markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf))
} }
if markupType != "" { if markupType != "" {
ctx.Data["HasSourceRenderedToggle"] = true ctx.Data["HasSourceRenderedToggle"] = true
} }
if markupType != "" && !shouldRenderSource { if markupType != "" && !shouldRenderSource {
ctx.Data["IsMarkup"] = true ctx.Data["IsMarkup"] = true
ctx.Data["MarkupType"] = markupType ctx.Data["MarkupType"] = markupType
if !detected {
markupType = ""
}
metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx)
metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL() metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL()
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{
Ctx: ctx, Ctx: ctx,
Type: markupType, MarkupType: markupType,
RelativePath: ctx.Repo.TreePath, RelativePath: ctx.Repo.TreePath,
Links: markup.Links{ Links: markup.Links{
Base: ctx.Repo.RepoLink, Base: ctx.Repo.RepoLink,
@ -615,6 +608,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["MarkupType"] = markupType ctx.Data["MarkupType"] = markupType
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{
Ctx: ctx, Ctx: ctx,
MarkupType: markupType,
RelativePath: ctx.Repo.TreePath, RelativePath: ctx.Repo.TreePath,
Links: markup.Links{ Links: markup.Links{
Base: ctx.Repo.RepoLink, Base: ctx.Repo.RepoLink,

@ -289,12 +289,12 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
} }
rctx := &markup.RenderContext{ rctx := &markup.RenderContext{
Ctx: ctx, Ctx: ctx,
Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), ContentMode: markup.RenderContentAsWiki,
Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx),
Links: markup.Links{ Links: markup.Links{
Base: ctx.Repo.RepoLink, Base: ctx.Repo.RepoLink,
}, },
IsWiki: true,
} }
buf := &strings.Builder{} buf := &strings.Builder{}

@ -258,7 +258,6 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
Base: profileDbRepo.Link(), Base: profileDbRepo.Link(),
BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
}, },
Metas: map[string]string{"mode": "document"},
}, bytes); err != nil { }, bytes); err != nil {
log.Error("failed to RenderString: %v", err) log.Error("failed to RenderString: %v", err)
} else { } else {

@ -260,8 +260,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
if len(ctx.ContextUser.Description) != 0 { if len(ctx.ContextUser.Description) != 0 {
content, err := markdown.RenderString(&markup.RenderContext{ content, err := markdown.RenderString(&markup.RenderContext{
Metas: map[string]string{"mode": "document"}, Ctx: ctx,
Ctx: ctx,
}, ctx.ContextUser.Description) }, ctx.ContextUser.Description)
if err != nil { if err != nil {
ctx.ServerError("RenderString", err) ctx.ServerError("RenderString", err)

@ -22615,7 +22615,7 @@
"type": "string" "type": "string"
}, },
"Mode": { "Mode": {
"description": "Mode to render (comment, gfm, markdown)\n\nin: body", "description": "Mode to render (markdown, comment, wiki, file)\n\nin: body",
"type": "string" "type": "string"
}, },
"Text": { "Text": {
@ -22623,7 +22623,7 @@
"type": "string" "type": "string"
}, },
"Wiki": { "Wiki": {
"description": "Is it a wiki page ?\n\nin: body", "description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true\nin: body",
"type": "boolean" "type": "boolean"
} }
}, },
@ -22642,7 +22642,7 @@
"type": "string" "type": "string"
}, },
"Mode": { "Mode": {
"description": "Mode to render (comment, gfm, markdown, file)\n\nin: body", "description": "Mode to render (markdown, comment, wiki, file)\n\nin: body",
"type": "string" "type": "string"
}, },
"Text": { "Text": {
@ -22650,7 +22650,7 @@
"type": "string" "type": "string"
}, },
"Wiki": { "Wiki": {
"description": "Is it a wiki page ?\n\nin: body", "description": "Is it a wiki page? (use mode=wiki instead)\n\nDeprecated: true\nin: body",
"type": "boolean" "type": "boolean"
} }
}, },

@ -10,6 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/external"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -23,10 +25,9 @@ func TestExternalMarkupRenderer(t *testing.T) {
return return
} }
const repoURL = "user30/renderer" req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
req := NewRequest(t, "GET", repoURL+"/src/branch/master/README.html")
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header()["Content-Type"][0]) assert.EqualValues(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err := io.ReadAll(resp.Body) bs, err := io.ReadAll(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
@ -36,4 +37,24 @@ func TestExternalMarkupRenderer(t *testing.T) {
data, err := div.Html() data, err := div.Html()
assert.NoError(t, err) assert.NoError(t, err)
assert.EqualValues(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data)) assert.EqualValues(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data))
r := markup.GetRendererByFileName("a.html").(*external.Renderer)
r.RenderContentMode = setting.RenderContentModeIframe
req = NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
resp = MakeRequest(t, req, http.StatusOK)
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
doc = NewHTMLParser(t, bytes.NewBuffer(bs))
iframe := doc.Find("iframe")
assert.EqualValues(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("src", ""))
req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
resp = MakeRequest(t, req, http.StatusOK)
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.EqualValues(t, "frame-src 'self'; sandbox allow-scripts", resp.Header().Get("Content-Security-Policy"))
assert.EqualValues(t, "<div>\n\ttest external renderer\n</div>\n", string(bs))
} }

@ -74,7 +74,6 @@ export class ComboMarkdownEditor {
previewUrl: string; previewUrl: string;
previewContext: string; previewContext: string;
previewMode: string; previewMode: string;
previewWiki: boolean;
constructor(container, options = {}) { constructor(container, options = {}) {
container._giteaComboMarkdownEditor = this; container._giteaComboMarkdownEditor = this;
@ -213,13 +212,11 @@ export class ComboMarkdownEditor {
this.previewUrl = this.tabPreviewer.getAttribute('data-preview-url'); this.previewUrl = this.tabPreviewer.getAttribute('data-preview-url');
this.previewContext = this.tabPreviewer.getAttribute('data-preview-context'); this.previewContext = this.tabPreviewer.getAttribute('data-preview-context');
this.previewMode = this.options.previewMode ?? 'comment'; this.previewMode = this.options.previewMode ?? 'comment';
this.previewWiki = this.options.previewWiki ?? false;
this.tabPreviewer.addEventListener('click', async () => { this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData(); const formData = new FormData();
formData.append('mode', this.previewMode); formData.append('mode', this.previewMode);
formData.append('context', this.previewContext); formData.append('context', this.previewContext);
formData.append('text', this.value()); formData.append('text', this.value());
formData.append('wiki', String(this.previewWiki));
const response = await POST(this.previewUrl, {data: formData}); const response = await POST(this.previewUrl, {data: formData});
const data = await response.text(); const data = await response.text();
renderPreviewPanelContent($(panelPreviewer), data); renderPreviewPanelContent($(panelPreviewer), data);

@ -26,7 +26,6 @@ async function initRepoWikiFormEditor() {
formData.append('mode', editor.previewMode); formData.append('mode', editor.previewMode);
formData.append('context', editor.previewContext); formData.append('context', editor.previewContext);
formData.append('text', newContent); formData.append('text', newContent);
formData.append('wiki', editor.previewWiki);
try { try {
const response = await POST(editor.previewUrl, {data: formData}); const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text(); const data = await response.text();
@ -51,8 +50,7 @@ async function initRepoWikiFormEditor() {
// And another benefit is that we only need to write the style once for both editors. // And another benefit is that we only need to write the style once for both editors.
// TODO: Move height style to CSS after EasyMDE removal. // TODO: Move height style to CSS after EasyMDE removal.
editorHeights: {minHeight: '300px', height: 'calc(100vh - 600px)'}, editorHeights: {minHeight: '300px', height: 'calc(100vh - 600px)'},
previewMode: 'gfm', previewMode: 'wiki',
previewWiki: true,
easyMDEOptions: { easyMDEOptions: {
previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
toolbar: ['bold', 'italic', 'strikethrough', '|', toolbar: ['bold', 'italic', 'strikethrough', '|',

Loading…
Cancel
Save