Support rendering OpenAPI spec (#36449)

Fix #20852
This commit is contained in:
wxiaoguang
2026-01-26 10:34:38 +08:00
committed by GitHub
parent 89bfddc5c2
commit 4c8f6dfa4e
27 changed files with 322 additions and 177 deletions

View File

@@ -22,8 +22,9 @@ func TestEntriesCustomSort(t *testing.T) {
&TreeEntry{name: "b-file", entryMode: EntryModeBlob},
}
expected := slices.Clone(entries)
rand.Shuffle(len(entries), func(i, j int) { entries[i], entries[j] = entries[j], entries[i] })
assert.NotEqual(t, expected, entries)
for slices.Equal(expected, entries) {
rand.Shuffle(len(entries), func(i, j int) { entries[i], entries[j] = entries[j], entries[i] })
}
entries.CustomSort(strings.Compare)
assert.Equal(t, expected, entries)
}

View File

@@ -20,14 +20,12 @@ func init() {
// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
type Renderer struct{}
// Name implements markup.Renderer
func (Renderer) Name() string {
return "asciicast"
}
// Extensions implements markup.Renderer
func (Renderer) Extensions() []string {
return []string{".cast"}
func (Renderer) FileNamePatterns() []string {
return []string{"*.cast"}
}
const (
@@ -35,12 +33,10 @@ const (
playerSrcAttr = "data-asciinema-player-src"
)
// SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}}
}
// Render implements markup.Renderer
func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error {
rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s",
setting.AppSubURL,

View File

@@ -20,29 +20,24 @@ func init() {
markup.RegisterRenderer(Renderer{})
}
// Renderer implements markup.Renderer
type Renderer struct{}
var _ markup.RendererContentDetector = (*Renderer)(nil)
// Name implements markup.Renderer
func (Renderer) Name() string {
return "console"
}
// Extensions implements markup.Renderer
func (Renderer) Extensions() []string {
return []string{".sh-session"}
func (Renderer) FileNamePatterns() []string {
return []string{"*.sh-session"}
}
// SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{
{Element: "span", AllowAttr: "class", Regexp: `^term-((fg[ix]?|bg)\d+|container)$`},
}
}
// CanRender implements markup.RendererContentDetector
func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool {
if !sniffedType.IsTextPlain() {
return false

View File

@@ -20,20 +20,16 @@ func init() {
markup.RegisterRenderer(Renderer{})
}
// Renderer implements markup.Renderer for csv files
type Renderer struct{}
// Name implements markup.Renderer
func (Renderer) Name() string {
return "csv"
}
// Extensions implements markup.Renderer
func (Renderer) Extensions() []string {
return []string{".csv", ".tsv"}
func (Renderer) FileNamePatterns() []string {
return []string{"*.csv", "*.tsv"}
}
// SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{
{Element: "table", AllowAttr: "class", Regexp: `^data-table$`},

View File

@@ -21,10 +21,9 @@ import (
// RegisterRenderers registers all supported third part renderers according settings
func RegisterRenderers() {
markup.RegisterRenderer(&openAPIRenderer{})
for _, renderer := range setting.ExternalMarkupRenderers {
if renderer.Enabled && renderer.Command != "" && len(renderer.FileExtensions) > 0 {
markup.RegisterRenderer(&Renderer{renderer})
}
markup.RegisterRenderer(&Renderer{renderer})
}
}
@@ -38,22 +37,18 @@ var (
_ markup.ExternalRenderer = (*Renderer)(nil)
)
// Name returns the external tool name
func (p *Renderer) Name() string {
return p.MarkupName
}
// NeedPostProcess implements markup.Renderer
func (p *Renderer) NeedPostProcess() bool {
return p.MarkupRenderer.NeedPostProcess
}
// Extensions returns the supported extensions of the tool
func (p *Renderer) Extensions() []string {
return p.FileExtensions
func (p *Renderer) FileNamePatterns() []string {
return p.FilePatterns
}
// SanitizerRules implements markup.Renderer
func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return p.MarkupSanitizerRules
}

79
modules/markup/external/openapi.go vendored Normal file
View File

@@ -0,0 +1,79 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package external
import (
"fmt"
"html"
"io"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
type openAPIRenderer struct{}
var (
_ markup.PostProcessRenderer = (*openAPIRenderer)(nil)
_ markup.ExternalRenderer = (*openAPIRenderer)(nil)
)
func (p *openAPIRenderer) Name() string {
return "openapi"
}
func (p *openAPIRenderer) NeedPostProcess() bool {
return false
}
func (p *openAPIRenderer) FileNamePatterns() []string {
return []string{
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
}
}
func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
return nil
}
func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
ret.SanitizerDisabled = true
ret.DisplayInIframe = true
ret.ContentSandbox = ""
return ret
}
func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
if err != nil {
return err
}
// TODO: can extract this to a tmpl file later
_, err = io.WriteString(output, fmt.Sprintf(
`<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="%s/assets/css/swagger.css?v=%s">
</head>
<body>
<div id="swagger-ui"><textarea class="swagger-spec-content" data-spec-filename="%s">%s</textarea></div>
<script src="%s/assets/js/swagger.js?v=%s"></script>
</body>
</html>`,
setting.StaticURLPrefix,
setting.AssetVersion,
html.EscapeString(ctx.RenderOptions.RelativePath),
html.EscapeString(util.UnsafeBytesToString(content)),
setting.StaticURLPrefix,
setting.AssetVersion,
))
return err
}

View File

@@ -14,5 +14,7 @@ import (
func TestMain(m *testing.M) {
setting.IsInTesting = true
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
setting.Markdown.FileNamePatterns = []string{"*.md"}
markup.RefreshFileNamePatterns()
os.Exit(m.Run())
}

View File

@@ -240,30 +240,24 @@ func init() {
markup.RegisterRenderer(Renderer{})
}
// Renderer implements markup.Renderer
type Renderer struct{}
var _ markup.PostProcessRenderer = (*Renderer)(nil)
// Name implements markup.Renderer
func (Renderer) Name() string {
return MarkupName
}
// NeedPostProcess implements markup.PostProcessRenderer
func (Renderer) NeedPostProcess() bool { return true }
// Extensions implements markup.Renderer
func (Renderer) Extensions() []string {
return setting.Markdown.FileExtensions
func (Renderer) FileNamePatterns() []string {
return setting.Markdown.FileNamePatterns
}
// SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{}
}
// Render implements markup.Renderer
func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
return render(ctx, input, output)
}

View File

@@ -31,20 +31,16 @@ var (
_ markup.PostProcessRenderer = (*renderer)(nil)
)
// Name implements markup.Renderer
func (renderer) Name() string {
return "orgmode"
}
// NeedPostProcess implements markup.PostProcessRenderer
func (renderer) NeedPostProcess() bool { return true }
// Extensions implements markup.Renderer
func (renderer) Extensions() []string {
return []string{".org"}
func (renderer) FileNamePatterns() []string {
return []string{"*.org"}
}
// SanitizerRules implements markup.Renderer
func (renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{}
}

View File

@@ -4,6 +4,7 @@
package markup
import (
"bytes"
"context"
"fmt"
"html/template"
@@ -16,6 +17,7 @@ import (
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"golang.org/x/sync/errgroup"
@@ -144,22 +146,29 @@ func (ctx *RenderContext) WithHelper(helper RenderHelper) *RenderContext {
return ctx
}
// FindRendererByContext finds renderer by RenderContext
// TODO: it should be merged with other similar functions like GetRendererByFileName, DetectMarkupTypeByFileName, etc
func FindRendererByContext(ctx *RenderContext) (Renderer, error) {
func (ctx *RenderContext) DetectMarkupRenderer(prefetchBuf []byte) Renderer {
if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" {
ctx.RenderOptions.MarkupType = DetectMarkupTypeByFileName(ctx.RenderOptions.RelativePath)
if ctx.RenderOptions.MarkupType == "" {
return nil, util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RenderOptions.RelativePath)
var sniffedType typesniffer.SniffedType
if len(prefetchBuf) > 0 {
sniffedType = typesniffer.DetectContentType(prefetchBuf)
}
ctx.RenderOptions.MarkupType = DetectRendererTypeByPrefetch(ctx.RenderOptions.RelativePath, sniffedType, prefetchBuf)
}
return renderers[ctx.RenderOptions.MarkupType]
}
renderer := renderers[ctx.RenderOptions.MarkupType]
func (ctx *RenderContext) DetectMarkupRendererByReader(in io.Reader) (Renderer, io.Reader, error) {
prefetchBuf := make([]byte, 512)
n, err := util.ReadAtMost(in, prefetchBuf)
if err != nil && err != io.EOF {
return nil, nil, err
}
prefetchBuf = prefetchBuf[:n]
renderer := ctx.DetectMarkupRenderer(prefetchBuf)
if renderer == nil {
return nil, util.NewNotExistErrorf("unsupported markup type: %q", ctx.RenderOptions.MarkupType)
return nil, nil, util.NewInvalidArgumentErrorf("unable to find a render")
}
return renderer, nil
return renderer, io.MultiReader(bytes.NewReader(prefetchBuf), in), nil
}
func RendererNeedPostProcess(renderer Renderer) bool {
@@ -170,12 +179,12 @@ func RendererNeedPostProcess(renderer Renderer) bool {
}
// Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
renderer, err := FindRendererByContext(ctx)
func Render(rctx *RenderContext, origInput io.Reader, output io.Writer) error {
renderer, input, err := rctx.DetectMarkupRendererByReader(origInput)
if err != nil {
return err
}
return RenderWithRenderer(ctx, renderer, input, output)
return RenderWithRenderer(rctx, renderer, input, output)
}
// RenderString renders Markup string to HTML with all specific handling stuff and return string
@@ -287,12 +296,14 @@ func Init(renderHelpFuncs *RenderHelperFuncs) {
}
// since setting maybe changed extensions, this will reload all renderer extensions mapping
extRenderers = make(map[string]Renderer)
fileNameRenderers = make(map[string]Renderer)
for _, renderer := range renderers {
for _, ext := range renderer.Extensions() {
extRenderers[strings.ToLower(ext)] = renderer
for _, pattern := range renderer.FileNamePatterns() {
fileNameRenderers[pattern] = renderer
}
}
RefreshFileNamePatterns()
}
func ComposeSimpleDocumentMetas() map[string]string {

View File

@@ -14,8 +14,8 @@ import (
// Renderer defines an interface for rendering markup file to HTML
type Renderer interface {
Name() string // markup format name
Extensions() []string
Name() string // markup format name, also the renderer type, also the external tool name
FileNamePatterns() []string
SanitizerRules() []setting.MarkupSanitizerRule
Render(ctx *RenderContext, input io.Reader, output io.Writer) error
}
@@ -43,26 +43,52 @@ type RendererContentDetector interface {
}
var (
extRenderers = make(map[string]Renderer)
renderers = make(map[string]Renderer)
fileNameRenderers = make(map[string]Renderer)
renderers = make(map[string]Renderer)
)
// RegisterRenderer registers a new markup file renderer
func RegisterRenderer(renderer Renderer) {
// TODO: need to handle conflicts
renderers[renderer.Name()] = renderer
for _, ext := range renderer.Extensions() {
extRenderers[strings.ToLower(ext)] = renderer
}
func RefreshFileNamePatterns() {
// TODO: need to handle conflicts
fileNameRenderers = make(map[string]Renderer)
for _, renderer := range renderers {
for _, ext := range renderer.FileNamePatterns() {
fileNameRenderers[strings.ToLower(ext)] = renderer
}
}
}
// GetRendererByFileName get renderer by filename
func GetRendererByFileName(filename string) Renderer {
extension := strings.ToLower(path.Ext(filename))
return extRenderers[extension]
func DetectRendererTypeByFilename(filename string) Renderer {
basename := path.Base(strings.ToLower(filename))
ext1 := path.Ext(basename)
if renderer := fileNameRenderers[basename]; renderer != nil {
return renderer
}
if renderer := fileNameRenderers["*"+ext1]; renderer != nil {
return renderer
}
if basename, ok := strings.CutSuffix(basename, ext1); ok {
ext2 := path.Ext(basename)
if renderer := fileNameRenderers["*"+ext2+ext1]; renderer != nil {
return renderer
}
}
return nil
}
// DetectRendererType detects the markup type of the content
func DetectRendererType(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) string {
// DetectRendererTypeByPrefetch detects the markup type of the content
func DetectRendererTypeByPrefetch(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) string {
if filename != "" {
byExt := DetectRendererTypeByFilename(filename)
if byExt != nil {
return byExt.Name()
}
}
for _, renderer := range renderers {
if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, sniffedType, prefetchBuf) {
return renderer.Name()
@@ -71,18 +97,12 @@ func DetectRendererType(filename string, sniffedType typesniffer.SniffedType, pr
return ""
}
// DetectMarkupTypeByFileName returns the possible markup format type via the filename
func DetectMarkupTypeByFileName(filename string) string {
if parser := GetRendererByFileName(filename); parser != nil {
return parser.Name()
}
return ""
}
func PreviewableExtensions() []string {
extensions := make([]string, 0, len(extRenderers))
for extension := range extRenderers {
extensions = append(extensions, extension)
exts := make([]string, 0, len(fileNameRenderers))
for p := range fileNameRenderers {
if s, ok := strings.CutPrefix(p, "*"); ok {
exts = append(exts, s)
}
}
return extensions
return exts
}

View File

@@ -6,6 +6,7 @@ package setting
import (
"regexp"
"strings"
"sync"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
@@ -43,22 +44,20 @@ var Markdown = struct {
RenderOptionsRepoFile MarkdownRenderOptions `ini:"-"`
CustomURLSchemes []string `ini:"CUSTOM_URL_SCHEMES"` // Actually it is a "markup" option because it is used in "post processor"
FileExtensions []string
FileNamePatterns []string `ini:"-"`
EnableMath bool
MathCodeBlockDetection []string
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
}{
FileExtensions: strings.Split(".md,.markdown,.mdown,.mkd,.livemd", ","),
EnableMath: true,
EnableMath: true,
}
// MarkupRenderer defines the external parser configured in ini
type MarkupRenderer struct {
Enabled bool
MarkupName string
Command string
FileExtensions []string
FilePatterns []string
IsInputFile bool
NeedPostProcess bool
MarkupSanitizerRules []MarkupSanitizerRule
@@ -77,6 +76,13 @@ type MarkupSanitizerRule struct {
func loadMarkupFrom(rootCfg ConfigProvider) {
mustMapSetting(rootCfg, "markdown", &Markdown)
markdownFileExtensions := rootCfg.Section("markdown").Key("FILE_EXTENSIONS").Strings(",")
if len(markdownFileExtensions) == 0 || len(markdownFileExtensions) == 1 && markdownFileExtensions[0] == "" {
markdownFileExtensions = []string{".md", ".markdown", ".mdown", ".mkd", ".livemd"}
}
Markdown.FileNamePatterns = fileExtensionsToPatterns("markdown", markdownFileExtensions)
const none = "none"
const renderOptionShortIssuePattern = "short-issue-pattern"
@@ -215,21 +221,30 @@ func createMarkupSanitizerRule(name string, sec ConfigSection) (MarkupSanitizerR
return rule, true
}
func newMarkupRenderer(name string, sec ConfigSection) {
extensionReg := regexp.MustCompile(`\.\w`)
var extensionReg = sync.OnceValue(func() *regexp.Regexp {
return regexp.MustCompile(`^(\.[-\w]+)+$`)
})
extensions := sec.Key("FILE_EXTENSIONS").Strings(",")
exts := make([]string, 0, len(extensions))
func fileExtensionsToPatterns(sectionName string, extensions []string) []string {
patterns := make([]string, 0, len(extensions))
for _, extension := range extensions {
if !extensionReg.MatchString(extension) {
log.Warn(sec.Name() + " file extension " + extension + " is invalid. Extension ignored")
if !extensionReg().MatchString(extension) {
log.Warn("Config section %s file extension %s is invalid. Extension ignored", sectionName, extension)
} else {
exts = append(exts, extension)
patterns = append(patterns, "*"+extension)
}
}
return patterns
}
if len(exts) == 0 {
log.Warn(sec.Name() + " file extension is empty, markup " + name + " ignored")
func newMarkupRenderer(name string, sec ConfigSection) {
if !sec.Key("ENABLED").MustBool(false) {
return
}
fileNamePatterns := fileExtensionsToPatterns(name, sec.Key("FILE_EXTENSIONS").Strings(","))
if len(fileNamePatterns) == 0 {
log.Warn("Config section %s file extension is empty, markup render is ignored", name)
return
}
@@ -262,11 +277,10 @@ func newMarkupRenderer(name string, sec ConfigSection) {
}
ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
Enabled: sec.Key("ENABLED").MustBool(false),
MarkupName: name,
FileExtensions: exts,
Command: command,
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
MarkupName: name,
FilePatterns: fileNamePatterns,
Command: command,
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
RenderContentMode: renderContentMode,
RenderContentSandbox: renderContentSandbox,