Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5525c17
Fix Swagger/OpenAPI rendering in dev mode and improve theme support
silverwind Apr 2, 2026
ea7d012
Address review feedback: sync dark mode on theme change, allow iframe…
silverwind Apr 3, 2026
31ecb83
Merge standalone Vite entry points into index.js
silverwind Apr 3, 2026
2b507dd
Merge origin/main into fixswag
silverwind Apr 3, 2026
70e237c
Fix double script injection in openapi iframe
silverwind Apr 3, 2026
6efaefd
Remove stale web_src/css/standalone/swagger.css
silverwind Apr 3, 2026
ed3b987
Restore external render, keep external-render-iframe standalone, add …
silverwind Apr 3, 2026
40a5f8e
Remove HeadScriptHTML, fix iframe dark mode and height
silverwind Apr 3, 2026
c4c538d
Merge branch 'main' into fixswag
silverwind Apr 3, 2026
0d0a548
Rename standalone/ and modules/ to pages/
silverwind Apr 3, 2026
8a1d47a
tweak
silverwind Apr 3, 2026
376666f
Remove redundant ErrAbortHandler recover in vitedev proxy
silverwind Apr 3, 2026
567eccc
remove lint exclusion
silverwind Apr 3, 2026
090f02a
Restore gitea-is-dark-theme URL parameter on iframe
silverwind Apr 3, 2026
76fc400
Remove dead jQuery check from devtest header
silverwind Apr 3, 2026
307d38d
Remove unnecessary comment in openapi.go
silverwind Apr 3, 2026
501470d
Restore HeadScriptHTML, remove manual window.config stub
silverwind Apr 3, 2026
01a7f09
Fix missing CSS var copy, log RenderToHTML error, parallelize swagger…
silverwind Apr 3, 2026
802a682
Revert parallel swagger imports to preserve CSS cascade order
silverwind Apr 3, 2026
8eb1127
Import swagger-ui CSS from swagger.css, parallelize JS/CSS imports
silverwind Apr 3, 2026
110f65f
refactor
wxiaoguang Apr 5, 2026
aecbe92
fix "standalone" path problem
wxiaoguang Apr 5, 2026
9512a4a
add comments
wxiaoguang Apr 5, 2026
924c1f7
fine tune comments
wxiaoguang Apr 5, 2026
54d9263
fix test
wxiaoguang Apr 5, 2026
9690716
Merge branch 'main' into fixswag
wxiaoguang Apr 5, 2026
82b08a6
add comment
wxiaoguang Apr 5, 2026
70f137b
remove unclear swagger css style
wxiaoguang Apr 5, 2026
e8e718f
fix unclear js name mapping
wxiaoguang Apr 5, 2026
c010dbc
add tests to cover RenderIFrame behavior
wxiaoguang Apr 5, 2026
d27ad93
simplify external render css injection
wxiaoguang Apr 5, 2026
fad6527
fix test
wxiaoguang Apr 5, 2026
3cb2cd1
Merge branch 'main' into fixswag
wxiaoguang Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions modules/markup/external/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,22 @@ func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
ret.SanitizerDisabled = true
ret.DisplayInIframe = true
ret.ContentSandbox = ""
ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
return ret
}

func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
if ctx.RenderOptions.StandalonePageOptions == nil {
opts := p.GetExternalRendererOptions()
return markup.RenderIFrame(ctx, &opts, output)
}

content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
if err != nil {
return err
}
// TODO: can extract this to a tmpl file later

// HINT: SWAGGER-OPENAPI-VIEWER: another place "templates/swagger/openapi-viewer.tmpl"
_, err = io.WriteString(output, fmt.Sprintf(
`<!DOCTYPE html>
<html>
Expand Down
37 changes: 22 additions & 15 deletions modules/markup/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ var RenderBehaviorForTesting struct {
DisableAdditionalAttributes bool
}

type WebThemeInterface interface {
PublicAssetURI() string
}

type StandalonePageOptions struct {
CurrentWebTheme WebThemeInterface
}

type RenderOptions struct {
UseAbsoluteLink bool

Expand All @@ -55,7 +63,7 @@ type RenderOptions struct {
Metas map[string]string

// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
InStandalonePage bool
StandalonePageOptions *StandalonePageOptions

// EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute.
// This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs.
Expand Down Expand Up @@ -127,8 +135,8 @@ func (ctx *RenderContext) WithMetas(metas map[string]string) *RenderContext {
return ctx
}

func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext {
ctx.RenderOptions.InStandalonePage = v
func (ctx *RenderContext) WithStandalonePage(opts StandalonePageOptions) *RenderContext {
ctx.RenderOptions.StandalonePageOptions = &opts
return ctx
}

Expand Down Expand Up @@ -197,20 +205,18 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
return buf.String(), nil
}

func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error {
func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error {
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
url.PathEscape(ctx.RenderOptions.Metas["user"]),
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
)

var sandboxAttrValue template.HTML
if sandbox != "" {
sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox)
var extraAttrs template.HTML
if opts.ContentSandbox != "" {
extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox)
}
iframe := htmlutil.HTMLFormat(`<iframe data-src="%s" class="external-render-iframe" %s></iframe>`, src, sandboxAttrValue)
_, err := io.WriteString(output, string(iframe))
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
return err
}

Expand All @@ -232,16 +238,17 @@ func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions,
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var extraHeadHTML template.HTML
if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
if !ctx.RenderOptions.InStandalonePage {
if ctx.RenderOptions.StandalonePageOptions == nil {
// 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, extOpts.ContentSandbox, output)
return RenderIFrame(ctx, &extOpts, output)
}
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
extraStyleHref := public.AssetURI("css/external-render-iframe.css")
extraScriptSrc := public.AssetURI("js/external-render-iframe.js")
extraScriptSrc := public.AssetURI("js/external-render-helper.js")
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
extraHeadHTML = htmlutil.HTMLFormat(`<script type="module" src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
extraHeadHTML = htmlutil.HTMLFormat(`<script crossorigin src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraLinkHref)
}

ctx.usedByRender = true
Expand Down
31 changes: 31 additions & 0 deletions modules/markup/render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package markup

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRenderIFrame(t *testing.T) {
render := func(ctx *RenderContext, opts ExternalRendererOptions) string {
sb := &strings.Builder{}
require.NoError(t, RenderIFrame(ctx, &opts, sb))
return sb.String()
}

ctx := NewRenderContext(t.Context()).
WithRelativePath("tree-path").
WithMetas(map[string]string{"user": "test-owner", "repo": "test-repo", "RefTypeNameSubURL": "src/branch/master"})

// the value is read from config RENDER_CONTENT_SANDBOX, empty means "disabled"
ret := render(ctx, ExternalRendererOptions{ContentSandbox: ""})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" class="external-render-iframe"></iframe>`, ret)

ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"})
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" class="external-render-iframe" sandbox="allow"></iframe>`, ret)
}
34 changes: 20 additions & 14 deletions modules/public/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,27 +125,33 @@ func getManifestData() *manifestDataStruct {
return data
}

// getHashedPath resolves an unhashed asset path (origin path) to its content-hashed path from the frontend manifest.
// Example: getHashedPath("js/index.js") returns "js/index.C6Z2MRVQ.js"
// Falls back to returning the input path unchanged if the manifest is unavailable.
func getHashedPath(originPath string) string {
data := getManifestData()
if p, ok := data.paths[originPath]; ok {
return p
}
return originPath
}

// AssetURI returns the URI for a frontend asset.
// It may return a relative path or a full URL depending on the StaticURLPrefix setting.
// In Vite dev mode, known entry points are mapped to their source paths
// so the reverse proxy serves them from the Vite dev server.
// In production, it resolves the content-hashed path from the manifest.
func AssetURI(originPath string) string {
if src := viteDevSourceURL(originPath); src != "" {
return src
if IsViteDevMode() {
if src := viteDevSourceURL(originPath); src != "" {
return src
}
// it should be caused by incorrect vite config
setting.PanicInDevOrTesting("Failed to locate local path for managed asset URI: %s", originPath)
}
return setting.StaticURLPrefix + "/assets/" + getHashedPath(originPath)

// Try to resolve an unhashed asset path (origin path) to its content-hashed path from the frontend manifest.
// Example: "js/index.js" -> "js/index.C6Z2MRVQ.js"
data := getManifestData()
assetPath := data.paths[originPath]
if assetPath == "" {
// it should be caused by either: "incorrect vite config" or "user's custom theme"
assetPath = originPath
if !setting.IsProd {
log.Warn("Failed to find managed asset URI for origin path: %s", originPath)
}
}

return setting.StaticURLPrefix + "/assets/" + assetPath
}

// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path.
Expand Down
11 changes: 0 additions & 11 deletions modules/public/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,6 @@ func TestViteManifest(t *testing.T) {
"isEntry": true,
"css": ["css/index.B3zrQPqD.css"]
},
"web_src/js/standalone/swagger.ts": {
"file": "js/swagger.SujiEmYM.js",
"name": "swagger",
"src": "web_src/js/standalone/swagger.ts",
"isEntry": true,
"css": ["css/swagger._-APWT_3.css"]
},
"web_src/css/themes/theme-gitea-dark.css": {
"file": "css/theme-gitea-dark.CyAaQnn5.css",
"name": "theme-gitea-dark",
Expand Down Expand Up @@ -62,12 +55,10 @@ func TestViteManifest(t *testing.T) {

// JS entries
assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"])
assert.Equal(t, "js/swagger.SujiEmYM.js", paths["js/swagger.js"])
assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"])

// Associated CSS from JS entries
assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"])
assert.Equal(t, "css/swagger._-APWT_3.css", paths["css/swagger.css"])

// CSS-only entries
assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"])
Expand All @@ -78,8 +69,6 @@ func TestViteManifest(t *testing.T) {
// Names: hashed path -> entry name
assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"])
assert.Equal(t, "index", names["css/index.B3zrQPqD.css"])
assert.Equal(t, "swagger", names["js/swagger.SujiEmYM.js"])
assert.Equal(t, "swagger", names["css/swagger._-APWT_3.css"])
assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"])
assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"])

Expand Down
11 changes: 11 additions & 0 deletions modules/public/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"

"github.com/go-chi/cors"
)

func CustomAssets() *assetfs.Layer {
Expand All @@ -28,6 +30,15 @@ func AssetFS() *assetfs.LayeredFS {
return assetfs.Layered(CustomAssets(), BuiltinAssets())
}

func AssetsCors() func(next http.Handler) http.Handler {
// static assets need to be served for external renders (sandboxed)
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"HEAD", "GET"},
MaxAge: 3600 * 24,
})
}

// FileHandlerFunc implements the static handler for serving files in "public" assets
func FileHandlerFunc() http.HandlerFunc {
assetFS := AssetFS()
Expand Down
43 changes: 23 additions & 20 deletions modules/public/vitedev.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/routing"
)

Expand Down Expand Up @@ -70,6 +71,9 @@ func getViteDevProxy() *httputil.ReverseProxy {
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
if r.Context().Err() != nil {
return // request cancelled (e.g. client disconnected), silently ignore
}
log.Error("Error proxying to Vite dev server: %v", err)
http.Error(w, "Error proxying to Vite dev server: "+err.Error(), http.StatusBadGateway)
},
Expand Down Expand Up @@ -136,32 +140,31 @@ func IsViteDevMode() bool {
return isDev
}

func viteDevSourceURL(name string) string {
if !IsViteDevMode() {
return ""
func detectWebSrcPath(webSrcPath string) string {
localPath := util.FilePathJoinAbs(setting.StaticRootPath, "web_src", webSrcPath)
if _, err := os.Stat(localPath); err == nil {
return setting.AppSubURL + "/web_src/" + webSrcPath
}
return ""
}

func viteDevSourceURL(name string) string {
if strings.HasPrefix(name, "css/theme-") {
// Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/
themeFile := strings.TrimPrefix(name, "css/")
srcPath := filepath.Join(setting.StaticRootPath, "web_src/css/themes", themeFile)
if _, err := os.Stat(srcPath); err == nil {
return setting.AppSubURL + "/web_src/css/themes/" + themeFile
themeFilePath := "css/themes/" + strings.TrimPrefix(name, "css/")
if srcPath := detectWebSrcPath(themeFilePath); srcPath != "" {
return srcPath
}
return ""
}
if strings.HasPrefix(name, "css/") {
return setting.AppSubURL + "/web_src/" + name
}
if name == "js/eventsource.sharedworker.js" {
return setting.AppSubURL + "/web_src/js/features/eventsource.sharedworker.ts"
}
if name == "js/iife.js" {
return setting.AppSubURL + "/web_src/js/__vite_iife.js"
}
if name == "js/index.js" {
return setting.AppSubURL + "/web_src/js/index.ts"
// try to map ".js" files to ".ts" files
pathPrefix, ok := strings.CutSuffix(name, ".js")
if ok {
if srcPath := detectWebSrcPath(pathPrefix + ".ts"); srcPath != "" {
return srcPath
}
}
return ""
// for all others that the names match
return detectWebSrcPath(name)
}

// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
Expand Down
8 changes: 1 addition & 7 deletions routers/web/misc/swagger.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,9 @@ package misc
import (
"net/http"

"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
)

// tplSwagger swagger page template
const tplSwagger templates.TplName = "swagger/ui"

// Swagger render swagger-ui page with v1 json
func Swagger(ctx *context.Context) {
ctx.Data["APIJSONVersion"] = "v1"
ctx.HTML(http.StatusOK, tplSwagger)
ctx.HTML(http.StatusOK, "swagger/openapi-viewer")
}
4 changes: 3 additions & 1 deletion routers/web/repo/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ func RenderFile(ctx *context.Context) {
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
}).WithRelativePath(ctx.Repo.TreePath).WithInStandalonePage(true)
}).WithRelativePath(ctx.Repo.TreePath).WithStandalonePage(markup.StandalonePageOptions{
CurrentWebTheme: ctx.TemplateContext.CurrentWebTheme(),
})
renderer, rendererInput, err := rctx.DetectMarkupRendererByReader(blobReader)
if err != nil {
http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest)
Expand Down
2 changes: 1 addition & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ func Routes() *web.Router {
routes.BeforeRouting(chi_middleware.GetHead)

routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc())
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
Expand Down
5 changes: 5 additions & 0 deletions services/webtheme/webtheme.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package webtheme

import (
"io/fs"
"net/url"
"os"
"path"
"regexp"
Expand Down Expand Up @@ -43,6 +44,10 @@ type ThemeMetaInfo struct {
ColorScheme string
}

func (info *ThemeMetaInfo) PublicAssetURI() string {
return public.AssetURI("css/theme-" + url.PathEscape(info.InternalName) + ".css")
}

func (info *ThemeMetaInfo) GetDescription() string {
if info.ColorblindType == "red-green" {
return "Red-green colorblind friendly"
Expand Down
2 changes: 1 addition & 1 deletion stylelint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default {
],
overrides: [
{
files: ['**/chroma/*', '**/codemirror/*', '**/standalone/*', '**/console.css', 'font_i18n.css'],
files: ['**/chroma/*', '**/codemirror/*', '**/console.css', 'font_i18n.css'],
rules: {
'scale-unlimited/declaration-strict-value': null,
},
Expand Down
Loading