diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 131fb3401e..d1cfcd70e5 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -993,10 +993,6 @@ ROUTER = console ;; List of file extensions for which lines should be wrapped in the Monaco editor ;; Separate extensions with a comma. To line wrap files without an extension, just put a comma ;LINE_WRAP_EXTENSIONS = .txt,.md,.markdown,.mdown,.mkd, -;; -;; Valid file modes that have a preview API associated with them, such as api/v1/markdown -;; Separate the values by commas. The preview tab in edit mode won't be displayed if the file extension doesn't match -;PREVIEWABLE_FILE_MODES = markdown ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index df2c9ebfc6..e934aed925 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -283,6 +283,11 @@ 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) } @@ -317,3 +322,11 @@ func IsMarkupFile(name, markup string) bool { } return false } + +func PreviewableExtensions() []string { + extensions := make([]string, 0, len(extRenderers)) + for extension := range extRenderers { + extensions = append(extensions, extension) + } + return extensions +} diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 4964704dba..bae3c658a4 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -53,8 +53,7 @@ var ( // Repository editor settings Editor struct { - LineWrapExtensions []string - PreviewableFileModes []string + LineWrapExtensions []string } `ini:"-"` // Repository upload settings @@ -167,11 +166,9 @@ var ( // Repository editor settings Editor: struct { - LineWrapExtensions []string - PreviewableFileModes []string + LineWrapExtensions []string }{ - LineWrapExtensions: strings.Split(".txt,.md,.markdown,.mdown,.mkd,", ","), - PreviewableFileModes: []string{"markdown"}, + LineWrapExtensions: strings.Split(".txt,.md,.markdown,.mdown,.mkd,", ","), }, // Repository upload settings diff --git a/modules/structs/miscellaneous.go b/modules/structs/miscellaneous.go index 596a551e0d..8acea84d6c 100644 --- a/modules/structs/miscellaneous.go +++ b/modules/structs/miscellaneous.go @@ -15,13 +15,41 @@ type SearchError struct { Error string `json:"error"` } +// MarkupOption markup options +type MarkupOption struct { + // Text markup to render + // + // in: body + Text string + // Mode to render (comment, gfm, markdown, file) + // + // in: body + Mode string + // Context to render + // + // in: body + Context string + // Is it a wiki page ? + // + // in: body + Wiki bool + // File path for detecting extension in file mode + // + // in: body + FilePath string +} + +// MarkupRender is a rendered markup document +// swagger:response MarkupRender +type MarkupRender string + // MarkdownOption markdown options type MarkdownOption struct { // Text markdown to render // // in: body Text string - // Mode to render + // Mode to render (comment, gfm, markdown) // // in: body Mode string diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index c3a875e737..8fd824640f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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() { diff --git a/routers/api/v1/misc/markdown.go b/routers/api/v1/misc/markup.go similarity index 56% rename from routers/api/v1/misc/markdown.go rename to routers/api/v1/misc/markup.go index 3ff42f08d6..93d5754444 100644 --- a/routers/api/v1/misc/markdown.go +++ b/routers/api/v1/misc/markup.go @@ -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 diff --git a/routers/api/v1/misc/markdown_test.go b/routers/api/v1/misc/markup_test.go similarity index 71% rename from routers/api/v1/misc/markdown_test.go rename to routers/api/v1/misc/markup_test.go index 025f2f44b0..301f51eea2 100644 --- a/routers/api/v1/misc/markdown_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -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) {
Guardfile-DSL / Configuring-Guard
+`, + // special syntax + `[[Name|Link]]`, + // rendered + ` +`, + // 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 `, - // Guard wiki sidebar: special syntax - `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`, - // rendered - `Guardfile-DSL / Configuring-Guard
-`, - // special syntax - `[[Name|Link]]`, - // rendered - ` -`, - // 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{ diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 0c8d3d353f..1ddc93c383 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -56,6 +56,8 @@ type swaggerParameterBodies struct { // in:body EditLabelOption api.EditLabelOption + // in:body + MarkupOption api.MarkupOption // in:body MarkdownOption api.MarkdownOption diff --git a/routers/common/markup.go b/routers/common/markup.go new file mode 100644 index 0000000000..89f24e0007 --- /dev/null +++ b/routers/common/markup.go @@ -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 + } +} diff --git a/routers/web/misc/markdown.go b/routers/web/misc/markdown.go deleted file mode 100644 index aaa3ed0781..0000000000 --- a/routers/web/misc/markdown.go +++ /dev/null @@ -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 - } - } -} diff --git a/routers/web/misc/markup.go b/routers/web/misc/markup.go new file mode 100644 index 0000000000..f678316f44 --- /dev/null +++ b/routers/web/misc/markup.go @@ -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) +} diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 07241b8870..2b66be22ae 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -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() { diff --git a/routers/web/web.go b/routers/web/web.go index 292268dc80..4bd2f76c57 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 6dafe6f896..8926c518ae 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -192,7 +192,7 @@