Editor preview support for external renderers (#23333)

Remove `[repository.editor] PREVIEWABLE_FILE_MODES` setting that seemed
like it was intended to support this but did not work. Instead, whenever
viewing a file shows a preview, also have a Preview tab in the file
editor.

Add new `/markup` web and API endpoints with `comment`, `gfm`,
`markdown` and new `file` mode that uses a file path to determine the
renderer.

Remove `/markdown` web endpoint but keep the API for backwards and
GitHub compatibility.

## ⚠️ BREAKING ⚠️

The `[repository.editor] PREVIEWABLE_FILE_MODES` setting was removed.
This setting served no practical purpose and was not working correctly.
Instead a preview tab is always shown in the file editor when supported.

---------

Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Brecht Van Lommel 2023-03-24 07:12:23 +01:00 committed by GitHub
parent 9e04627aca
commit 84daddc2fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 389 additions and 215 deletions

View file

@ -711,6 +711,7 @@ func Routes(ctx gocontext.Context) *web.Route {
})
}
m.Get("/signing-key.gpg", misc.SigningKey)
m.Post("/markup", bind(api.MarkupOption{}), misc.Markup)
m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown)
m.Post("/markdown/raw", misc.MarkdownRaw)
m.Group("/settings", func() {
@ -1034,6 +1035,7 @@ func Routes(ctx gocontext.Context) *web.Route {
Patch(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditLabelOption{}), repo.EditLabel).
Delete(reqToken(auth_model.AccessTokenScopeRepo), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteLabel)
})
m.Post("/markup", reqToken(auth_model.AccessTokenScopeRepo), bind(api.MarkupOption{}), misc.Markup)
m.Post("/markdown", reqToken(auth_model.AccessTokenScopeRepo), bind(api.MarkdownOption{}), misc.Markdown)
m.Post("/markdown/raw", reqToken(auth_model.AccessTokenScopeRepo), misc.MarkdownRaw)
m.Group("/milestones", func() {

View file

@ -5,19 +5,45 @@ package misc
import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"mvdan.cc/xurls/v2"
"code.gitea.io/gitea/routers/common"
)
// Markup render markup document to HTML
func Markup(ctx *context.APIContext) {
// swagger:operation POST /markup miscellaneous renderMarkup
// ---
// summary: Render a markup document as HTML
// parameters:
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/MarkupOption"
// consumes:
// - application/json
// produces:
// - text/html
// responses:
// "200":
// "$ref": "#/responses/MarkupRender"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.MarkupOption)
if ctx.HasAPIError() {
ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg())
return
}
common.RenderMarkup(ctx.Context, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki)
}
// Markdown render markdown document to HTML
func Markdown(ctx *context.APIContext) {
// swagger:operation POST /markdown miscellaneous renderMarkdown
@ -45,55 +71,12 @@ func Markdown(ctx *context.APIContext) {
return
}
if len(form.Text) == 0 {
_, _ = ctx.Write([]byte(""))
return
mode := "markdown"
if form.Mode == "comment" || form.Mode == "gfm" {
mode = form.Mode
}
switch form.Mode {
case "comment":
fallthrough
case "gfm":
urlPrefix := form.Context
meta := map[string]string{}
if !strings.HasPrefix(setting.AppSubURL+"/", urlPrefix) {
// check if urlPrefix is already set to a URL
linkRegex, _ := xurls.StrictMatchingScheme("https?://")
m := linkRegex.FindStringIndex(urlPrefix)
if m == nil {
urlPrefix = util.URLJoin(setting.AppURL, form.Context)
}
}
if ctx.Repo != nil && ctx.Repo.Repository != nil {
// "gfm" = Github Flavored Markdown - set this to render as a document
if form.Mode == "gfm" {
meta = ctx.Repo.Repository.ComposeDocumentMetas()
} else {
meta = ctx.Repo.Repository.ComposeMetas()
}
}
if form.Mode == "gfm" {
meta["mode"] = "document"
}
if err := markdown.Render(&markup.RenderContext{
Ctx: ctx,
URLPrefix: urlPrefix,
Metas: meta,
IsWiki: form.Wiki,
}, strings.NewReader(form.Text), ctx.Resp); err != nil {
ctx.InternalServerError(err)
return
}
default:
if err := markdown.RenderRaw(&markup.RenderContext{
Ctx: ctx,
URLPrefix: form.Context,
}, strings.NewReader(form.Text), ctx.Resp); err != nil {
ctx.InternalServerError(err)
return
}
}
common.RenderMarkup(ctx.Context, mode, form.Text, form.Context, "", form.Wiki)
}
// MarkdownRaw render raw markdown HTML

View file

@ -49,16 +49,37 @@ func wrap(ctx *context.Context) *context.APIContext {
}
}
func TestAPI_RenderGFM(t *testing.T) {
func testRenderMarkup(t *testing.T, mode, filePath, text, responseBody string, responseCode int) {
setting.AppURL = AppURL
options := api.MarkupOption{
Mode: mode,
Text: "",
Context: Repo,
Wiki: true,
FilePath: filePath,
}
requrl, _ := url.Parse(util.URLJoin(AppURL, "api", "v1", "markup"))
req := &http.Request{
Method: "POST",
URL: requrl,
}
m, resp := createContext(req)
ctx := wrap(m)
options.Text = text
web.SetForm(ctx, &options)
Markup(ctx)
assert.Equal(t, responseBody, resp.Body.String())
assert.Equal(t, responseCode, resp.Code)
resp.Body.Reset()
}
func testRenderMarkdown(t *testing.T, mode, text, responseBody string, responseCode int) {
setting.AppURL = AppURL
markup.Init(&markup.ProcessorHelper{
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
return username == "r-lyeh"
},
})
options := api.MarkdownOption{
Mode: "gfm",
Mode: mode,
Text: "",
Context: Repo,
Wiki: true,
@ -71,7 +92,22 @@ func TestAPI_RenderGFM(t *testing.T) {
m, resp := createContext(req)
ctx := wrap(m)
testCases := []string{
options.Text = text
web.SetForm(ctx, &options)
Markdown(ctx)
assert.Equal(t, responseBody, resp.Body.String())
assert.Equal(t, responseCode, resp.Code)
resp.Body.Reset()
}
func TestAPI_RenderGFM(t *testing.T) {
markup.Init(&markup.ProcessorHelper{
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
return username == "r-lyeh"
},
})
testCasesCommon := []string{
// dear imgui wiki markdown extract: special wiki syntax
`Wiki! Enjoy :)
- [[Links, Language bindings, Engine bindings|Links]]
@ -85,6 +121,23 @@ func TestAPI_RenderGFM(t *testing.T) {
<li>Bezier widget (by <a href="` + AppURL + `r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="https://github.com/ocornut/imgui/issues/786" rel="nofollow">https://github.com/ocornut/imgui/issues/786</a></li>
</ul>
`,
// Guard wiki sidebar: special syntax
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
// rendered
`<p><a href="` + AppSubURL + `wiki/Guardfile-DSL---Configuring-Guard" rel="nofollow">Guardfile-DSL / Configuring-Guard</a></p>
`,
// special syntax
`[[Name|Link]]`,
// rendered
`<p><a href="` + AppSubURL + `wiki/Link" rel="nofollow">Name</a></p>
`,
// empty
``,
// rendered
``,
}
testCasesDocument := []string{
// wine-staging wiki home extract: special wiki syntax, images
`## What is Wine Staging?
**Wine Staging** on website [wine-staging.com](http://wine-staging.com).
@ -103,29 +156,28 @@ Here are some links to the most important topics. You can find the full list of
<p><a href="` + AppSubURL + `wiki/Configuration" rel="nofollow">Configuration</a>
<a href="` + AppSubURL + `wiki/raw/images/icon-bug.png" rel="nofollow"><img src="` + AppSubURL + `wiki/raw/images/icon-bug.png" title="icon-bug.png" alt="images/icon-bug.png"/></a></p>
`,
// Guard wiki sidebar: special syntax
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
// rendered
`<p><a href="` + AppSubURL + `wiki/Guardfile-DSL---Configuring-Guard" rel="nofollow">Guardfile-DSL / Configuring-Guard</a></p>
`,
// special syntax
`[[Name|Link]]`,
// rendered
`<p><a href="` + AppSubURL + `wiki/Link" rel="nofollow">Name</a></p>
`,
// empty
``,
// rendered
``,
}
for i := 0; i < len(testCases); i += 2 {
options.Text = testCases[i]
web.SetForm(ctx, &options)
Markdown(ctx)
assert.Equal(t, testCases[i+1], resp.Body.String())
resp.Body.Reset()
for i := 0; i < len(testCasesCommon); i += 2 {
text := testCasesCommon[i]
response := testCasesCommon[i+1]
testRenderMarkdown(t, "gfm", text, response, http.StatusOK)
testRenderMarkup(t, "gfm", "", text, response, http.StatusOK)
testRenderMarkdown(t, "comment", text, response, http.StatusOK)
testRenderMarkup(t, "comment", "", text, response, http.StatusOK)
testRenderMarkup(t, "file", "path/test.md", text, response, http.StatusOK)
}
for i := 0; i < len(testCasesDocument); i += 2 {
text := testCasesDocument[i]
response := testCasesDocument[i+1]
testRenderMarkdown(t, "gfm", text, response, http.StatusOK)
testRenderMarkup(t, "gfm", "", text, response, http.StatusOK)
testRenderMarkup(t, "file", "path/test.md", text, response, http.StatusOK)
}
testRenderMarkup(t, "file", "path/test.unknown", "## Test", "Unsupported render extension: .unknown\n", http.StatusUnprocessableEntity)
testRenderMarkup(t, "unknown", "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity)
}
var simpleCases = []string{

View file

@ -56,6 +56,8 @@ type swaggerParameterBodies struct {
// in:body
EditLabelOption api.EditLabelOption
// in:body
MarkupOption api.MarkupOption
// in:body
MarkdownOption api.MarkdownOption

92
routers/common/markup.go Normal file
View file

@ -0,0 +1,92 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"net/http"
"strings"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"mvdan.cc/xurls/v2"
)
// RenderMarkup renders markup text for the /markup and /markdown endpoints
func RenderMarkup(ctx *context.Context, mode, text, urlPrefix, filePath string, wiki bool) {
markupType := ""
relativePath := ""
if len(text) == 0 {
_, _ = ctx.Write([]byte(""))
return
}
switch mode {
case "markdown":
// Raw markdown
if err := markdown.RenderRaw(&markup.RenderContext{
Ctx: ctx,
URLPrefix: urlPrefix,
}, strings.NewReader(text), ctx.Resp); err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
}
return
case "comment":
// Comment as markdown
markupType = markdown.MarkupName
case "gfm":
// Github Flavored Markdown as document
markupType = markdown.MarkupName
case "file":
// File as document based on file extension
markupType = ""
relativePath = filePath
default:
ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Unknown mode: %s", mode))
return
}
if !strings.HasPrefix(setting.AppSubURL+"/", urlPrefix) {
// check if urlPrefix is already set to a URL
linkRegex, _ := xurls.StrictMatchingScheme("https?://")
m := linkRegex.FindStringIndex(urlPrefix)
if m == nil {
urlPrefix = util.URLJoin(setting.AppURL, urlPrefix)
}
}
meta := map[string]string{}
if ctx.Repo != nil && ctx.Repo.Repository != nil {
if mode == "comment" {
meta = ctx.Repo.Repository.ComposeMetas()
} else {
meta = ctx.Repo.Repository.ComposeDocumentMetas()
}
}
if mode != "comment" {
meta["mode"] = "document"
}
if err := markup.Render(&markup.RenderContext{
Ctx: ctx,
URLPrefix: urlPrefix,
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())
} else {
ctx.Error(http.StatusInternalServerError, err.Error())
}
return
}
}

View file

@ -1,98 +0,0 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package misc
import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"mvdan.cc/xurls/v2"
)
// Markdown render markdown document to HTML
func Markdown(ctx *context.Context) {
// swagger:operation POST /markdown miscellaneous renderMarkdown
// ---
// summary: Render a markdown document as HTML
// parameters:
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/MarkdownOption"
// consumes:
// - application/json
// produces:
// - text/html
// responses:
// "200":
// "$ref": "#/responses/MarkdownRender"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.MarkdownOption)
if ctx.HasAPIError() {
ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg())
return
}
if len(form.Text) == 0 {
_, _ = ctx.Write([]byte(""))
return
}
switch form.Mode {
case "comment":
fallthrough
case "gfm":
urlPrefix := form.Context
meta := map[string]string{}
if !strings.HasPrefix(setting.AppSubURL+"/", urlPrefix) {
// check if urlPrefix is already set to a URL
linkRegex, _ := xurls.StrictMatchingScheme("https?://")
m := linkRegex.FindStringIndex(urlPrefix)
if m == nil {
urlPrefix = util.URLJoin(setting.AppURL, form.Context)
}
}
if ctx.Repo != nil && ctx.Repo.Repository != nil {
// "gfm" = Github Flavored Markdown - set this to render as a document
if form.Mode == "gfm" {
meta = ctx.Repo.Repository.ComposeDocumentMetas()
} else {
meta = ctx.Repo.Repository.ComposeMetas()
}
}
if form.Mode == "gfm" {
meta["mode"] = "document"
}
if err := markdown.Render(&markup.RenderContext{
Ctx: ctx,
URLPrefix: urlPrefix,
Metas: meta,
IsWiki: form.Wiki,
}, strings.NewReader(form.Text), ctx.Resp); err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
default:
if err := markdown.RenderRaw(&markup.RenderContext{
Ctx: ctx,
URLPrefix: form.Context,
}, strings.NewReader(form.Text), ctx.Resp); err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
}
}

View file

@ -0,0 +1,44 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package misc
import (
"net/http"
"code.gitea.io/gitea/modules/context"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common"
)
// Markup render markup document to HTML
func Markup(ctx *context.Context) {
// swagger:operation POST /markup miscellaneous renderMarkup
// ---
// summary: Render a markup document as HTML
// parameters:
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/MarkupOption"
// consumes:
// - application/json
// produces:
// - text/html
// responses:
// "200":
// "$ref": "#/responses/MarkupRender"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.MarkupOption)
if ctx.HasAPIError() {
ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg())
return
}
common.RenderMarkup(ctx, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki)
}

View file

@ -20,6 +20,7 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/upload"
@ -155,9 +156,8 @@ func editFile(ctx *context.Context, isNewFile bool) {
}
ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx)
ctx.Data["last_commit"] = ctx.Repo.CommitID
ctx.Data["MarkdownFileExts"] = strings.Join(setting.Markdown.FileExtensions, ",")
ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",")
ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",")
ctx.Data["PreviewableFileModes"] = strings.Join(setting.Repository.Editor.PreviewableFileModes, ",")
ctx.Data["Editorconfig"] = GetEditorConfig(ctx, treePath)
ctx.HTML(http.StatusOK, tplEditFile)
@ -207,9 +207,8 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b
ctx.Data["commit_choice"] = form.CommitChoice
ctx.Data["new_branch_name"] = form.NewBranchName
ctx.Data["last_commit"] = ctx.Repo.CommitID
ctx.Data["MarkdownFileExts"] = strings.Join(setting.Markdown.FileExtensions, ",")
ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",")
ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",")
ctx.Data["PreviewableFileModes"] = strings.Join(setting.Repository.Editor.PreviewableFileModes, ",")
ctx.Data["Editorconfig"] = GetEditorConfig(ctx, form.TreePath)
if ctx.HasError() {

View file

@ -1115,7 +1115,7 @@ func RegisterRoutes(m *web.Route) {
m.Group("/comments/{id}", func() {
m.Get("/attachments", repo.GetCommentAttachments)
})
m.Post("/markdown", web.Bind(structs.MarkdownOption{}), misc.Markdown)
m.Post("/markup", web.Bind(structs.MarkupOption{}), misc.Markup)
m.Group("/labels", func() {
m.Post("/new", web.Bind(forms.CreateLabelForm{}), repo.NewLabel)
m.Post("/edit", web.Bind(forms.CreateLabelForm{}), repo.UpdateLabel)