From 5525c17cca9798f52738114e674136ba7c4363bf Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 2 Apr 2026 20:05:56 +0200 Subject: [PATCH 01/29] Fix Swagger/OpenAPI rendering in dev mode and improve theme support The webpack-to-vite migration broke OpenAPI document rendering in dev mode because viteDevSourceURL blindly mapped all css/* paths to /web_src/css/*, but standalone CSS like swagger.css lives in web_src/css/standalone/ and needs its bundled npm dependencies. Replace the blind css/ mapping with a generic file-existence check that works for both CSS and JS. Consolidate JS if-chains into a switch and add a path traversal guard. For theme support, load the user's Gitea theme CSS on the /api/swagger page so swagger.ts can read --is-dark-theme instead of relying on prefers-color-scheme (which ignores the user's Gitea theme preference). For the iframe case, inject --is-dark-theme and --color-box-body from the parent page into the iframe document on load. Hide the iframe with is-loading until content is ready to prevent color flashes. Co-Authored-By: Claude (Opus 4.6) --- modules/markup/render.go | 2 +- modules/public/vitedev.go | 20 ++++++++++++-------- templates/swagger/ui.tmpl | 1 + web_src/css/markup/content.css | 5 +++++ web_src/css/standalone/swagger.css | 24 +++++++++++++----------- web_src/js/markup/render-iframe.ts | 16 +++++++++++++--- web_src/js/standalone/swagger.ts | 12 +++++++----- 7 files changed, 52 insertions(+), 28 deletions(-) diff --git a/modules/markup/render.go b/modules/markup/render.go index c0d44c72fcca1..991256acfc285 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -209,7 +209,7 @@ func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error { if sandbox != "" { sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox) } - iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) + iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) _, err := io.WriteString(output, string(iframe)) return err } diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 25bd28a826573..53e89ef1c1731 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -149,18 +149,22 @@ func viteDevSourceURL(name string) string { } return "" } - if strings.HasPrefix(name, "css/") { - return setting.AppSubURL + "/web_src/" + name - } - if name == "js/eventsource.sharedworker.js" { + switch name { + case "js/eventsource.sharedworker.js": return setting.AppSubURL + "/web_src/js/features/eventsource.sharedworker.ts" - } - if name == "js/iife.js" { + case "js/iife.js": return setting.AppSubURL + "/web_src/js/__vite_iife.js" - } - if name == "js/index.js" { + case "js/index.js": return setting.AppSubURL + "/web_src/js/index.ts" } + webSrcDir := filepath.Join(setting.StaticRootPath, "web_src") + srcPath := filepath.Join(webSrcDir, name) + if !strings.HasPrefix(srcPath, webSrcDir+string(filepath.Separator)) { + return "" + } + if _, err := os.Stat(srcPath); err == nil { + return setting.AppSubURL + "/web_src/" + name + } return "" } diff --git a/templates/swagger/ui.tmpl b/templates/swagger/ui.tmpl index d53a6111764ef..a1dc79cb46b68 100644 --- a/templates/swagger/ui.tmpl +++ b/templates/swagger/ui.tmpl @@ -2,6 +2,7 @@ Gitea API + diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index 78cd025e41a92..5c256afd2fad9 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -527,6 +527,11 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .external-render-iframe { width: 100%; height: max(300px, 80vh); + border: none; +} + +.external-render-iframe.is-loading { + visibility: hidden; } .markup-content-iframe { diff --git a/web_src/css/standalone/swagger.css b/web_src/css/standalone/swagger.css index e65af5ded6354..6c65cfea44d49 100644 --- a/web_src/css/standalone/swagger.css +++ b/web_src/css/standalone/swagger.css @@ -6,7 +6,13 @@ body { margin: 0; - background: #fff; + background: var(--color-box-body); +} + +html.dark-mode, +html.dark-mode .swagger-ui, +html.dark-mode .swagger-ui .scheme-container { + background: var(--color-box-body); } .swagger-back-link { @@ -19,16 +25,12 @@ body { align-items: center; } -@media (prefers-color-scheme: dark) { - body { - background: #1c2022; - } - .swagger-back-link { - color: #51a8ff; - } - .swagger-ui table.headers td { - color: #aeb4c4; /** fix low contrast */ - } +html.dark-mode .swagger-back-link { + color: #51a8ff; +} + +html.dark-mode .swagger-ui table.headers td { + color: #aeb4c4; /** fix low contrast */ } .swagger-back-link:hover { diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 531942e0b1ab8..1ee3cd2d18057 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -1,5 +1,4 @@ import {generateElemId, queryElemChildren} from '../utils/dom.ts'; -import {isDarkTheme} from '../utils.ts'; function safeRenderIframeLink(link: any): string | null { try { @@ -38,7 +37,7 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { if (!e.data?.giteaIframeCmd || e.data?.giteaIframeId !== iframe.id) return; const cmd = e.data.giteaIframeCmd; if (cmd === 'resize') { - iframe.style.height = `${e.data.iframeHeight}px`; + if (!iframe.classList.contains('is-loading')) iframe.style.height = `${e.data.iframeHeight}px`; } else if (cmd === 'open-link') { navigateToIframeLink(e.data.openLink, e.data.anchorTarget); } else { @@ -46,8 +45,19 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { } }); + iframe.addEventListener('load', () => { + try { + const parentStyle = getComputedStyle(document.documentElement); + const iframeRoot = iframe.contentDocument!.documentElement; + for (const prop of ['--color-box-body', '--is-dark-theme']) { + const value = parentStyle.getPropertyValue(prop).trim(); + if (value) iframeRoot.style.setProperty(prop, value); + } + } catch { /* cross-origin — ignore */ } + iframe.classList.remove('is-loading'); + }); + const u = new URL(iframeSrcUrl, window.location.origin); - u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme())); u.searchParams.set('gitea-iframe-id', iframe.id); iframe.src = u.href; } diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/standalone/swagger.ts index fc44c8501ace8..f42c140603562 100644 --- a/web_src/js/standalone/swagger.ts +++ b/web_src/js/standalone/swagger.ts @@ -1,12 +1,14 @@ -import '../../css/standalone/swagger.css'; import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; import 'swagger-ui-dist/swagger-ui.css'; +import '../../css/standalone/swagger.css'; import {load as loadYaml} from 'js-yaml'; -const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); -const apply = () => document.documentElement.classList.toggle('dark-mode', prefersDark.matches); -apply(); -prefersDark.addEventListener('change', apply); +function isDarkTheme(): boolean { + const value = getComputedStyle(document.documentElement).getPropertyValue('--is-dark-theme').trim().toLowerCase(); + if (value) return value === 'true'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} +document.documentElement.classList.toggle('dark-mode', isDarkTheme()); window.addEventListener('load', async () => { const elSwaggerUi = document.querySelector('#swagger-ui')!; From ea7d012e1307f6dc2433de9729b00d0448790449 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 19:04:44 +0200 Subject: [PATCH 02/29] Address review feedback: sync dark mode on theme change, allow iframe resize during loading - Add matchMedia change listener so swagger dark-mode class updates when system color scheme changes at runtime (relevant for auto theme) - Remove is-loading guard on iframe resize messages so the correct height is set before the iframe becomes visible Co-Authored-By: Claude (Opus 4.6) --- web_src/js/markup/render-iframe.ts | 2 +- web_src/js/standalone/swagger.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 1ee3cd2d18057..1072d0a4db0e1 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -37,7 +37,7 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { if (!e.data?.giteaIframeCmd || e.data?.giteaIframeId !== iframe.id) return; const cmd = e.data.giteaIframeCmd; if (cmd === 'resize') { - if (!iframe.classList.contains('is-loading')) iframe.style.height = `${e.data.iframeHeight}px`; + iframe.style.height = `${e.data.iframeHeight}px`; } else if (cmd === 'open-link') { navigateToIframeLink(e.data.openLink, e.data.anchorTarget); } else { diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/standalone/swagger.ts index f42c140603562..083f0d68908a2 100644 --- a/web_src/js/standalone/swagger.ts +++ b/web_src/js/standalone/swagger.ts @@ -8,7 +8,13 @@ function isDarkTheme(): boolean { if (value) return value === 'true'; return window.matchMedia('(prefers-color-scheme: dark)').matches; } -document.documentElement.classList.toggle('dark-mode', isDarkTheme()); + +function syncDarkModeClass(): void { + document.documentElement.classList.toggle('dark-mode', isDarkTheme()); +} + +syncDarkModeClass(); +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); window.addEventListener('load', async () => { const elSwaggerUi = document.querySelector('#swagger-ui')!; From 31ecb8386d101f5b5a4d4777d6d08766f28ccbb3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 21:02:34 +0200 Subject: [PATCH 03/29] Merge standalone Vite entry points into index.js Remove standalone JS entry points (swagger, devtest, external-render-iframe) from the Vite config. Move their init logic into modules/ and register them in index.ts's callInitFunctions array. Standalone pages (openapi iframe, external render iframe) now load iife.js + index.js via head_script template. - Move swagger/devtest/external-render-iframe JS into modules/ - Delete web_src/js/standalone/ and web_src/css/standalone/ - Keep swagger.css and devtest.css as CSS-only Vite entries - Swagger template and openapi.go use head_script + index.js - Pass pre-rendered HeadScriptHTML from router to markup renderer - Use `isDarkTheme()` with matchMedia fallback for swagger dark mode - Use `data-gitea-theme-dark` attribute for swagger CSS overrides - Use CSS vars instead of hardcoded colors in swagger.css - Guard customElements.define to prevent re-registration in iframes - Suppress context-canceled proxy errors in vitedev.go Co-Authored-By: Claude (Opus 4.6) --- modules/markup/external/openapi.go | 4 +- modules/markup/render.go | 14 ++++-- modules/public/manifest_test.go | 11 ----- modules/public/vitedev.go | 8 ++++ routers/web/repo/render.go | 9 +++- templates/devtest/devtest-footer.tmpl | 2 - templates/swagger/ui.tmpl | 5 ++- tests/integration/markup_external_test.go | 6 ++- vite.config.ts | 7 +-- web_src/css/{standalone => }/devtest.css | 0 .../css/standalone/external-render-iframe.css | 1 - web_src/css/swagger.css | 44 +++++++++++++++++++ web_src/js/index.ts | 7 +++ web_src/js/{standalone => modules}/devtest.ts | 13 +++--- .../external-render-iframe.ts | 24 ++-------- web_src/js/{standalone => modules}/swagger.ts | 27 ++++++------ web_src/js/utils.ts | 5 ++- web_src/js/webcomponents/origin-url.ts | 2 +- web_src/js/webcomponents/overflow-menu.ts | 2 +- web_src/js/webcomponents/relative-time.ts | 2 +- 20 files changed, 118 insertions(+), 75 deletions(-) rename web_src/css/{standalone => }/devtest.css (100%) delete mode 100644 web_src/css/standalone/external-render-iframe.css create mode 100644 web_src/css/swagger.css rename web_src/js/{standalone => modules}/devtest.ts (50%) rename web_src/js/{standalone => modules}/external-render-iframe.ts (77%) rename web_src/js/{standalone => modules}/swagger.ts (67%) diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index de06e7dac7023..baa07a0421b51 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -62,6 +62,7 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out + %s @@ -69,10 +70,11 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out `, + string(ctx.RenderOptions.HeadScriptHTML), public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), - public.AssetURI("js/swagger.js"), + public.AssetURI("js/index.js"), )) return err } diff --git a/modules/markup/render.go b/modules/markup/render.go index 991256acfc285..4dc3bfda3a557 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -57,6 +57,9 @@ type RenderOptions struct { // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page InStandalonePage bool + // pre-rendered HTML from base/head_script template, injected into standalone pages for external renderers + HeadScriptHTML template.HTML + // 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. EnableHeadingIDGeneration bool @@ -132,6 +135,11 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext { return ctx } +func (ctx *RenderContext) WithHeadScriptHTML(v template.HTML) *RenderContext { + ctx.RenderOptions.HeadScriptHTML = v + return ctx +} + func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext { ctx.RenderOptions.EnableHeadingIDGeneration = v return ctx @@ -238,10 +246,8 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, return renderIFrame(ctx, extOpts.ContentSandbox, 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, extraStyleHref) + indexSrc := public.AssetURI("js/index.js") + extraHeadHTML = ctx.RenderOptions.HeadScriptHTML + htmlutil.HTMLFormat(``, indexSrc) } ctx.usedByRender = true diff --git a/modules/public/manifest_test.go b/modules/public/manifest_test.go index 20a2232cf3896..acfeaa6dbeb39 100644 --- a/modules/public/manifest_test.go +++ b/modules/public/manifest_test.go @@ -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", @@ -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"]) @@ -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"]) diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 53e89ef1c1731..ce386164c8108 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -70,6 +70,9 @@ func getViteDevProxy() *httputil.ReverseProxy { return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + if r.Context().Err() != nil { + return // 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) }, @@ -94,6 +97,11 @@ func ViteDevMiddleware(next http.Handler) http.Handler { return } routing.MarkLongPolling(resp, req) + defer func() { + if r := recover(); r != nil && r != http.ErrAbortHandler { + panic(r) + } + }() proxy.ServeHTTP(resp, req) }) } diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index b1299c7047e49..e919add8f2953 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -4,6 +4,8 @@ package repo import ( + "fmt" + "html/template" "net/http" "path" @@ -11,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/services/context" ) @@ -39,10 +42,14 @@ func RenderFile(ctx *context.Context) { } defer blobReader.Close() + themeCSSLink := template.HTML(fmt.Sprintf(``, public.AssetURI(fmt.Sprintf("css/theme-%s.css", ctx.TemplateContext.CurrentWebTheme().InternalName)))) + headScriptHTML, _ := ctx.RenderToHTML("base/head_script", ctx.Data) + headScriptHTML = themeCSSLink + headScriptHTML + 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).WithInStandalonePage(true).WithHeadScriptHTML(headScriptHTML) renderer, rendererInput, err := rctx.DetectMarkupRendererByReader(blobReader) if err != nil { http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest) diff --git a/templates/devtest/devtest-footer.tmpl b/templates/devtest/devtest-footer.tmpl index 868136e1948e0..091a1035a9fa7 100644 --- a/templates/devtest/devtest-footer.tmpl +++ b/templates/devtest/devtest-footer.tmpl @@ -1,3 +1 @@ -{{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}} - {{template "base/footer" ctx.RootData}} diff --git a/templates/swagger/ui.tmpl b/templates/swagger/ui.tmpl index a1dc79cb46b68..3aedc13eb1784 100644 --- a/templates/swagger/ui.tmpl +++ b/templates/swagger/ui.tmpl @@ -3,13 +3,14 @@ Gitea API - + + {{template "base/head_script" .}} {{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}} {{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}
- + {{ScriptImport "js/index.js" "module"}} diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index 3d9d7b3969670..f18da0a18fe9a 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -108,7 +108,8 @@ func TestExternalMarkupRenderer(t *testing.T) { // default sandbox in sub page response assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "
<script></script>
`, respSub.Body.String()) + assert.Contains(t, respSub.Body.String(), ``) + assert.Contains(t, respSub.Body.String(), `
<script></script>
`) }) }) @@ -131,7 +132,8 @@ func TestExternalMarkupRenderer(t *testing.T) { t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") respSub := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, ``, respSub.Body.String()) + assert.Contains(t, respSub.Body.String(), ``) + assert.Contains(t, respSub.Body.String(), ``) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) }) }) diff --git a/vite.config.ts b/vite.config.ts index 4a3e271a3cc35..1578061a9a652 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -246,12 +246,9 @@ export default defineConfig(commonViteOpts({ rolldownOptions: { input: { index: join(import.meta.dirname, 'web_src/js/index.ts'), - swagger: join(import.meta.dirname, 'web_src/js/standalone/swagger.ts'), - 'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'), 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'), - ...(!isProduction && { - devtest: join(import.meta.dirname, 'web_src/js/standalone/devtest.ts'), - }), + swagger: join(import.meta.dirname, 'web_src/css/swagger.css'), + devtest: join(import.meta.dirname, 'web_src/css/devtest.css'), ...themes, }, output: { diff --git a/web_src/css/standalone/devtest.css b/web_src/css/devtest.css similarity index 100% rename from web_src/css/standalone/devtest.css rename to web_src/css/devtest.css diff --git a/web_src/css/standalone/external-render-iframe.css b/web_src/css/standalone/external-render-iframe.css deleted file mode 100644 index 2997587d820c9..0000000000000 --- a/web_src/css/standalone/external-render-iframe.css +++ /dev/null @@ -1 +0,0 @@ -/* dummy */ diff --git a/web_src/css/swagger.css b/web_src/css/swagger.css new file mode 100644 index 0000000000000..d812e2eff33c5 --- /dev/null +++ b/web_src/css/swagger.css @@ -0,0 +1,44 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-box-body); +} + +html[data-gitea-theme-dark="true"], +html[data-gitea-theme-dark="true"] .swagger-ui, +html[data-gitea-theme-dark="true"] .swagger-ui .scheme-container { + background: var(--color-box-body) !important; +} + +.swagger-back-link { + color: var(--color-primary); + text-decoration: none; + position: absolute; + top: 1rem; + right: 1.5rem; + display: flex; + align-items: center; +} + +html[data-gitea-theme-dark="true"] .swagger-ui table.headers td { + color: var(--color-text) !important; +} + +.swagger-back-link:hover { + text-decoration: underline; +} + +.swagger-back-link svg { + color: inherit; + fill: currentcolor; + margin-right: 0.5rem; +} + +.swagger-spec-content { + display: none; +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index d4d5ea6cffff2..b7d54b8273b29 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -67,6 +67,9 @@ import {callInitFunctions} from './modules/init.ts'; import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; import {initActionsPermissionsForm} from './features/common-actions-permissions.ts'; import {initGlobalShortcut} from './modules/shortcut.ts'; +import {initSwagger} from './modules/swagger.ts'; +import {initDevtest} from './modules/devtest.ts'; +import {initExternalRenderIframe} from './modules/external-render-iframe.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ @@ -160,6 +163,10 @@ const initPerformanceTracer = callInitFunctions([ initRepoFileView, initActionsPermissionsForm, + + initSwagger, + initDevtest, + initExternalRenderIframe, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/modules/devtest.ts similarity index 50% rename from web_src/js/standalone/devtest.ts rename to web_src/js/modules/devtest.ts index 20ab163d1a27b..67aeb980039e3 100644 --- a/web_src/js/standalone/devtest.ts +++ b/web_src/js/modules/devtest.ts @@ -1,11 +1,13 @@ -import '../../css/standalone/devtest.css'; -import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts'; +import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts'; +import type {Toast} from './toast.ts'; type LevelMap = Record Toast | null>; -function initDevtestToast() { +export function initDevtest() { + const els = document.querySelectorAll('.toast-test-button'); + if (!els.length) return; const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; - for (const el of document.querySelectorAll('.toast-test-button')) { + for (const el of els) { el.addEventListener('click', () => { const level = el.getAttribute('data-toast-level')!; const message = el.getAttribute('data-toast-message')!; @@ -13,6 +15,3 @@ function initDevtestToast() { }); } } - -// NOTICE: keep in mind that this file is not in "index.js", they do not share the same module system. -initDevtestToast(); diff --git a/web_src/js/standalone/external-render-iframe.ts b/web_src/js/modules/external-render-iframe.ts similarity index 77% rename from web_src/js/standalone/external-render-iframe.ts rename to web_src/js/modules/external-render-iframe.ts index 3b489f8ee382b..ec0c4149a85dd 100644 --- a/web_src/js/standalone/external-render-iframe.ts +++ b/web_src/js/modules/external-render-iframe.ts @@ -1,21 +1,7 @@ -/* To manually test: - -[markup.in-iframe] -ENABLED = true -FILE_EXTENSIONS = .in-iframe -RENDER_CONTENT_MODE = iframe -RENDER_COMMAND = `echo ''` - -;RENDER_COMMAND = cat /path/to/file.pdf -;RENDER_CONTENT_SANDBOX = disabled - -*/ - -import '../../css/standalone/external-render-iframe.css'; - -function mainExternalRenderIframe() { - const u = new URL(window.location.href); - const iframeId = u.searchParams.get('gitea-iframe-id'); +export function initExternalRenderIframe() { + const url = new URL(window.location.href); + const iframeId = url.searchParams.get('gitea-iframe-id'); + if (!iframeId) return; // iframe is in different origin, so we need to use postMessage to communicate const postIframeMsg = (cmd: string, data: Record = {}) => { @@ -54,5 +40,3 @@ function mainExternalRenderIframe() { } }); } - -mainExternalRenderIframe(); diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/modules/swagger.ts similarity index 67% rename from web_src/js/standalone/swagger.ts rename to web_src/js/modules/swagger.ts index 083f0d68908a2..5f690f1770330 100644 --- a/web_src/js/standalone/swagger.ts +++ b/web_src/js/modules/swagger.ts @@ -1,23 +1,22 @@ -import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; -import 'swagger-ui-dist/swagger-ui.css'; -import '../../css/standalone/swagger.css'; import {load as loadYaml} from 'js-yaml'; - -function isDarkTheme(): boolean { - const value = getComputedStyle(document.documentElement).getPropertyValue('--is-dark-theme').trim().toLowerCase(); - if (value) return value === 'true'; - return window.matchMedia('(prefers-color-scheme: dark)').matches; -} +import {isDarkTheme} from '../utils.ts'; function syncDarkModeClass(): void { document.documentElement.classList.toggle('dark-mode', isDarkTheme()); } -syncDarkModeClass(); -window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); +export async function initSwagger() { + const elSwaggerUi = document.querySelector('#swagger-ui'); + if (!elSwaggerUi) return; + + // swagger-ui has built-in dark mode triggered by html.dark-mode class + syncDarkModeClass(); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); + + const {default: SwaggerUI} = await import('swagger-ui-dist/swagger-ui-es-bundle.js'); + await import('swagger-ui-dist/swagger-ui.css'); + await import('../../css/swagger.css'); -window.addEventListener('load', async () => { - const elSwaggerUi = document.querySelector('#swagger-ui')!; const url = elSwaggerUi.getAttribute('data-source')!; let spec: any; if (url) { @@ -53,4 +52,4 @@ window.addEventListener('load', async () => { SwaggerUI.plugins.DownloadUrl, ], }); -}); +} diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index 23bddc3b1c013..76ae24452b9b9 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -37,8 +37,9 @@ export const keySymbols: Record = isMac ? /** returns whether a dark theme is enabled */ export function isDarkTheme(): boolean { - const style = window.getComputedStyle(document.documentElement); - return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true'; + const value = getComputedStyle(document.documentElement).getPropertyValue('--is-dark-theme').trim().toLowerCase(); + if (value) return value === 'true'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; } /** strip from a string */ diff --git a/web_src/js/webcomponents/origin-url.ts b/web_src/js/webcomponents/origin-url.ts index e39d3ad7130b0..890000015593d 100644 --- a/web_src/js/webcomponents/origin-url.ts +++ b/web_src/js/webcomponents/origin-url.ts @@ -1,6 +1,6 @@ import {toOriginUrl} from '../utils/url.ts'; -window.customElements.define('origin-url', class extends HTMLElement { +if (!window.customElements.get('origin-url')) window.customElements.define('origin-url', class extends HTMLElement { connectedCallback() { this.textContent = toOriginUrl(this.getAttribute('data-url')!); } diff --git a/web_src/js/webcomponents/overflow-menu.ts b/web_src/js/webcomponents/overflow-menu.ts index 879ce36e6e309..cda5aa6318deb 100644 --- a/web_src/js/webcomponents/overflow-menu.ts +++ b/web_src/js/webcomponents/overflow-menu.ts @@ -2,7 +2,7 @@ import {throttle} from 'throttle-debounce'; import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg'; -window.customElements.define('overflow-menu', class extends HTMLElement { +if (!window.customElements.get('overflow-menu')) window.customElements.define('overflow-menu', class extends HTMLElement { popup: HTMLDivElement; overflowItems: Array; button: HTMLButtonElement | null; diff --git a/web_src/js/webcomponents/relative-time.ts b/web_src/js/webcomponents/relative-time.ts index 8a5d98873371f..db5c22a813e29 100644 --- a/web_src/js/webcomponents/relative-time.ts +++ b/web_src/js/webcomponents/relative-time.ts @@ -503,4 +503,4 @@ class RelativeTime extends HTMLElement { } } -window.customElements.define('relative-time', RelativeTime); +if (!window.customElements.get('relative-time')) window.customElements.define('relative-time', RelativeTime); From 70e237c72c5eee0929122d595d809645ee449a81 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 21:18:28 +0200 Subject: [PATCH 04/29] Fix double script injection in openapi iframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove HeadScriptHTML from openapi.go's HTML output since RenderWithRenderer already prepends it via extraHeadHTML. This caused iife.js to load twice, triggering double customElements.define. Also revert the customElements.get() guards — no longer needed. Co-Authored-By: Claude (Opus 4.6) --- modules/markup/external/openapi.go | 6 +----- web_src/js/webcomponents/overflow-menu.ts | 2 +- web_src/js/webcomponents/relative-time.ts | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index baa07a0421b51..a5044d496bfe5 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -56,25 +56,21 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out if err != nil { return err } - // TODO: can extract this to a tmpl file later + // head_script + index.js are prepended by RenderWithRenderer via extraHeadHTML _, err = io.WriteString(output, fmt.Sprintf( ` - %s
- `, - string(ctx.RenderOptions.HeadScriptHTML), public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), - public.AssetURI("js/index.js"), )) return err } diff --git a/web_src/js/webcomponents/overflow-menu.ts b/web_src/js/webcomponents/overflow-menu.ts index cda5aa6318deb..879ce36e6e309 100644 --- a/web_src/js/webcomponents/overflow-menu.ts +++ b/web_src/js/webcomponents/overflow-menu.ts @@ -2,7 +2,7 @@ import {throttle} from 'throttle-debounce'; import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg'; -if (!window.customElements.get('overflow-menu')) window.customElements.define('overflow-menu', class extends HTMLElement { +window.customElements.define('overflow-menu', class extends HTMLElement { popup: HTMLDivElement; overflowItems: Array; button: HTMLButtonElement | null; diff --git a/web_src/js/webcomponents/relative-time.ts b/web_src/js/webcomponents/relative-time.ts index db5c22a813e29..8a5d98873371f 100644 --- a/web_src/js/webcomponents/relative-time.ts +++ b/web_src/js/webcomponents/relative-time.ts @@ -503,4 +503,4 @@ class RelativeTime extends HTMLElement { } } -if (!window.customElements.get('relative-time')) window.customElements.define('relative-time', RelativeTime); +window.customElements.define('relative-time', RelativeTime); From 6efaefdd8ff7626616825fac1fd495a81de2dc43 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 21:20:16 +0200 Subject: [PATCH 05/29] Remove stale web_src/css/standalone/swagger.css Co-Authored-By: Claude (Opus 4.6) --- web_src/css/standalone/swagger.css | 48 ------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 web_src/css/standalone/swagger.css diff --git a/web_src/css/standalone/swagger.css b/web_src/css/standalone/swagger.css deleted file mode 100644 index 6c65cfea44d49..0000000000000 --- a/web_src/css/standalone/swagger.css +++ /dev/null @@ -1,48 +0,0 @@ -*, -*::before, -*::after { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--color-box-body); -} - -html.dark-mode, -html.dark-mode .swagger-ui, -html.dark-mode .swagger-ui .scheme-container { - background: var(--color-box-body); -} - -.swagger-back-link { - color: #4990e2; - text-decoration: none; - position: absolute; - top: 1rem; - right: 1.5rem; - display: flex; - align-items: center; -} - -html.dark-mode .swagger-back-link { - color: #51a8ff; -} - -html.dark-mode .swagger-ui table.headers td { - color: #aeb4c4; /** fix low contrast */ -} - -.swagger-back-link:hover { - text-decoration: underline; -} - -.swagger-back-link svg { - color: inherit; - fill: currentcolor; - margin-right: 0.5rem; -} - -.swagger-spec-content { - display: none; -} From ed3b987e41eefe422d5cf65a040aa2ef88119034 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 22:29:04 +0200 Subject: [PATCH 06/29] Restore external render, keep external-render-iframe standalone, add e2e tests - Generic external render iframes use lightweight external-render-iframe.js only - OpenAPI iframes use HeadScriptHTML + index.js for full SwaggerUI support - Restore render-iframe.ts and content.css to match main behavior - Add e2e tests for both generic external render and OpenAPI iframe - Add [markup.test-external] config to e2e test server Co-Authored-By: Claude (Opus 4.6) --- modules/markup/external/openapi.go | 8 ++- modules/markup/render.go | 8 +-- modules/public/vitedev.go | 2 + tests/e2e/external-render.test.ts | 53 +++++++++++++++++++ tests/e2e/utils.ts | 7 +++ tests/integration/markup_external_test.go | 6 +-- tools/test-e2e.sh | 7 +++ vite.config.ts | 1 + web_src/css/markup/content.css | 5 -- .../css/standalone/external-render-iframe.css | 1 + web_src/js/index.ts | 2 - web_src/js/markup/render-iframe.ts | 14 +---- web_src/js/modules/external-render-iframe.ts | 13 +++++ .../js/standalone/external-render-iframe.ts | 4 ++ 14 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 tests/e2e/external-render.test.ts create mode 100644 web_src/css/standalone/external-render-iframe.css create mode 100644 web_src/js/standalone/external-render-iframe.ts diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index a5044d496bfe5..41c493111278b 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -56,21 +56,27 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out if err != nil { return err } - // head_script + index.js are prepended by RenderWithRenderer via extraHeadHTML + // TODO: can extract this to a tmpl file later + // HeadScriptHTML provides iife.js + window.config + theme CSS; index.js provides initSwagger + // external-render-iframe.js is additionally prepended by RenderWithRenderer via extraHeadHTML _, err = io.WriteString(output, fmt.Sprintf( ` + %s
+ `, + ctx.RenderOptions.HeadScriptHTML, public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), + public.AssetURI("js/index.js"), )) return err } diff --git a/modules/markup/render.go b/modules/markup/render.go index 4dc3bfda3a557..c33be92483316 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -217,7 +217,7 @@ func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error { if sandbox != "" { sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox) } - iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) + iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) _, err := io.WriteString(output, string(iframe)) return err } @@ -246,8 +246,10 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, return renderIFrame(ctx, extOpts.ContentSandbox, output) } // else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS - indexSrc := public.AssetURI("js/index.js") - extraHeadHTML = ctx.RenderOptions.HeadScriptHTML + htmlutil.HTMLFormat(``, indexSrc) + extraStyleHref := public.AssetURI("css/external-render-iframe.css") + extraScriptSrc := public.AssetURI("js/external-render-iframe.js") + // "`, extraScriptSrc, extraStyleHref) } ctx.usedByRender = true diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index ce386164c8108..211783b6bb26e 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -164,6 +164,8 @@ func viteDevSourceURL(name string) string { return setting.AppSubURL + "/web_src/js/__vite_iife.js" case "js/index.js": return setting.AppSubURL + "/web_src/js/index.ts" + case "js/external-render-iframe.js": + return setting.AppSubURL + "/web_src/js/standalone/external-render-iframe.ts" } webSrcDir := filepath.Join(setting.StaticRootPath, "web_src") srcPath := filepath.Join(webSrcDir, name) diff --git a/tests/e2e/external-render.test.ts b/tests/e2e/external-render.test.ts new file mode 100644 index 0000000000000..f1d0c638c7cd3 --- /dev/null +++ b/tests/e2e/external-render.test.ts @@ -0,0 +1,53 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertNoJsError, randomString} from './utils.ts'; + +test('external render file displays in iframe', async ({page, request}) => { + const repoName = `e2e-external-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await Promise.all([ + apiCreateRepo(request, {name: repoName}), + login(page), + ]); + try { + await apiCreateFile(request, owner, repoName, 'test.external', '

rendered content

'); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`); + + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`)); + + // verify rendered content appears inside the iframe + const frame = page.frameLocator('iframe.external-render-iframe'); + await expect(frame.locator('p')).toContainText('rendered content'); + + await assertNoJsError(page); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); + +test('openapi file renders swagger in iframe', async ({page, request}) => { + const repoName = `e2e-openapi-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await Promise.all([ + apiCreateRepo(request, {name: repoName}), + login(page), + ]); + try { + const spec = 'openapi: "3.0.0"\ninfo:\n title: Test API\n version: "1.0"\npaths: {}\n'; + await apiCreateFile(request, owner, repoName, 'openapi.yaml', spec); + await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.yaml`); + + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + + // verify SwaggerUI renders inside the iframe + const frame = page.frameLocator('iframe.external-render-iframe'); + await expect(frame.locator('#swagger-ui .swagger-ui')).toBeVisible(); + + await assertNoJsError(page); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 442a40d8e56f9..7a4a91c2699b9 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -60,6 +60,13 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner }), 'apiStartStopwatch'); } +export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string) { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, { + headers: apiHeaders(), + data: {content: globalThis.btoa(content)}, + }), 'apiCreateFile'); +} + export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, { headers: apiHeaders(), diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index f18da0a18fe9a..3d9d7b3969670 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -108,8 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) { // default sandbox in sub page response assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "`) - assert.Contains(t, respSub.Body.String(), `
<script></script>
`) + assert.Equal(t, `
<script></script>
`, respSub.Body.String()) }) }) @@ -132,8 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) { t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") respSub := MakeRequest(t, req, http.StatusOK) - assert.Contains(t, respSub.Body.String(), ``) - assert.Contains(t, respSub.Body.String(), ``) + assert.Equal(t, ``, respSub.Body.String()) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) }) }) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index ed3d8c402e4d3..b8bf671839200 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -40,6 +40,13 @@ EVENT_SOURCE_UPDATE_TIME = 500ms [log] MODE = console LEVEL = Warn + +[markup.test-external] +ENABLED = true +FILE_EXTENSIONS = .external +RENDER_COMMAND = cat +IS_INPUT_FILE = false +RENDER_CONTENT_MODE = iframe EOF export GITEA_WORK_DIR="$WORK_DIR" diff --git a/vite.config.ts b/vite.config.ts index 3e3a5ca29ee2f..a277f513def2d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -245,6 +245,7 @@ export default defineConfig(commonViteOpts({ rolldownOptions: { input: { index: join(import.meta.dirname, 'web_src/js/index.ts'), + 'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'), 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'), swagger: join(import.meta.dirname, 'web_src/css/swagger.css'), devtest: join(import.meta.dirname, 'web_src/css/devtest.css'), diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index 275126a50417a..d0655af002711 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -529,11 +529,6 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .external-render-iframe { width: 100%; height: max(300px, 80vh); - border: none; -} - -.external-render-iframe.is-loading { - visibility: hidden; } .markup-content-iframe { diff --git a/web_src/css/standalone/external-render-iframe.css b/web_src/css/standalone/external-render-iframe.css new file mode 100644 index 0000000000000..2997587d820c9 --- /dev/null +++ b/web_src/css/standalone/external-render-iframe.css @@ -0,0 +1 @@ +/* dummy */ diff --git a/web_src/js/index.ts b/web_src/js/index.ts index b7d54b8273b29..6e9d48e274803 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -69,7 +69,6 @@ import {initActionsPermissionsForm} from './features/common-actions-permissions. import {initGlobalShortcut} from './modules/shortcut.ts'; import {initSwagger} from './modules/swagger.ts'; import {initDevtest} from './modules/devtest.ts'; -import {initExternalRenderIframe} from './modules/external-render-iframe.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ @@ -166,7 +165,6 @@ const initPerformanceTracer = callInitFunctions([ initSwagger, initDevtest, - initExternalRenderIframe, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 1072d0a4db0e1..531942e0b1ab8 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -1,4 +1,5 @@ import {generateElemId, queryElemChildren} from '../utils/dom.ts'; +import {isDarkTheme} from '../utils.ts'; function safeRenderIframeLink(link: any): string | null { try { @@ -45,19 +46,8 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { } }); - iframe.addEventListener('load', () => { - try { - const parentStyle = getComputedStyle(document.documentElement); - const iframeRoot = iframe.contentDocument!.documentElement; - for (const prop of ['--color-box-body', '--is-dark-theme']) { - const value = parentStyle.getPropertyValue(prop).trim(); - if (value) iframeRoot.style.setProperty(prop, value); - } - } catch { /* cross-origin — ignore */ } - iframe.classList.remove('is-loading'); - }); - const u = new URL(iframeSrcUrl, window.location.origin); + u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme())); u.searchParams.set('gitea-iframe-id', iframe.id); iframe.src = u.href; } diff --git a/web_src/js/modules/external-render-iframe.ts b/web_src/js/modules/external-render-iframe.ts index ec0c4149a85dd..439be25856f9f 100644 --- a/web_src/js/modules/external-render-iframe.ts +++ b/web_src/js/modules/external-render-iframe.ts @@ -1,3 +1,16 @@ +/* To manually test: + +[markup.in-iframe] +ENABLED = true +FILE_EXTENSIONS = .in-iframe +RENDER_CONTENT_MODE = iframe +RENDER_COMMAND = `echo ''` + +;RENDER_COMMAND = cat /path/to/file.pdf +;RENDER_CONTENT_SANDBOX = disabled + +*/ + export function initExternalRenderIframe() { const url = new URL(window.location.href); const iframeId = url.searchParams.get('gitea-iframe-id'); diff --git a/web_src/js/standalone/external-render-iframe.ts b/web_src/js/standalone/external-render-iframe.ts new file mode 100644 index 0000000000000..d3d655d61c38a --- /dev/null +++ b/web_src/js/standalone/external-render-iframe.ts @@ -0,0 +1,4 @@ +import '../../css/standalone/external-render-iframe.css'; +import {initExternalRenderIframe} from '../modules/external-render-iframe.ts'; + +initExternalRenderIframe(); From 40a5f8eca97a0380ced23b5dc3deaf082f434f5b Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:18:59 +0200 Subject: [PATCH 07/29] Remove HeadScriptHTML, fix iframe dark mode and height Load iife.js + index.js directly from openapi.go with a minimal window.config stub. Remove HeadScriptHTML from RenderOptions and the template rendering from the router. Fix iframe height by using scrollHeight instead of getBoundingClientRect which returns viewport height when index.css sets html/body to 100%. Fix dark mode by copying CSS vars from parent to iframe on load and hiding iframe with is-loading until ready. Use html.dark-mode class in swagger.css instead of data-gitea-theme-dark attribute. Co-Authored-By: Claude (Opus 4.6) --- modules/markup/external/openapi.go | 8 +++++--- modules/markup/render.go | 10 +--------- routers/web/repo/render.go | 9 +-------- web_src/css/markup/content.css | 5 +++++ web_src/css/swagger.css | 14 +++++++------- web_src/js/markup/render-iframe.ts | 17 ++++++++++++++--- web_src/js/modules/external-render-iframe.ts | 8 ++++---- 7 files changed, 37 insertions(+), 34 deletions(-) diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index 41c493111278b..d03609257828e 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -57,14 +57,14 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out return err } // TODO: can extract this to a tmpl file later - // HeadScriptHTML provides iife.js + window.config + theme CSS; index.js provides initSwagger // external-render-iframe.js is additionally prepended by RenderWithRenderer via extraHeadHTML _, err = io.WriteString(output, fmt.Sprintf( ` - %s + + @@ -72,7 +72,9 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out `, - ctx.RenderOptions.HeadScriptHTML, + setting.AppSubURL, + setting.StaticURLPrefix+"/assets", + public.AssetURI("js/iife.js"), public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), diff --git a/modules/markup/render.go b/modules/markup/render.go index c33be92483316..991256acfc285 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -57,9 +57,6 @@ type RenderOptions struct { // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page InStandalonePage bool - // pre-rendered HTML from base/head_script template, injected into standalone pages for external renderers - HeadScriptHTML template.HTML - // 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. EnableHeadingIDGeneration bool @@ -135,11 +132,6 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext { return ctx } -func (ctx *RenderContext) WithHeadScriptHTML(v template.HTML) *RenderContext { - ctx.RenderOptions.HeadScriptHTML = v - return ctx -} - func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext { ctx.RenderOptions.EnableHeadingIDGeneration = v return ctx @@ -217,7 +209,7 @@ func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error { if sandbox != "" { sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox) } - iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) + iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) _, err := io.WriteString(output, string(iframe)) return err } diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index e919add8f2953..b1299c7047e49 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -4,8 +4,6 @@ package repo import ( - "fmt" - "html/template" "net/http" "path" @@ -13,7 +11,6 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/services/context" ) @@ -42,14 +39,10 @@ func RenderFile(ctx *context.Context) { } defer blobReader.Close() - themeCSSLink := template.HTML(fmt.Sprintf(``, public.AssetURI(fmt.Sprintf("css/theme-%s.css", ctx.TemplateContext.CurrentWebTheme().InternalName)))) - headScriptHTML, _ := ctx.RenderToHTML("base/head_script", ctx.Data) - headScriptHTML = themeCSSLink + headScriptHTML - 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).WithHeadScriptHTML(headScriptHTML) + }).WithRelativePath(ctx.Repo.TreePath).WithInStandalonePage(true) renderer, rendererInput, err := rctx.DetectMarkupRendererByReader(blobReader) if err != nil { http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest) diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index d0655af002711..275126a50417a 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -529,6 +529,11 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .external-render-iframe { width: 100%; height: max(300px, 80vh); + border: none; +} + +.external-render-iframe.is-loading { + visibility: hidden; } .markup-content-iframe { diff --git a/web_src/css/swagger.css b/web_src/css/swagger.css index d812e2eff33c5..72a3c507edd01 100644 --- a/web_src/css/swagger.css +++ b/web_src/css/swagger.css @@ -9,12 +9,16 @@ body { background: var(--color-box-body); } -html[data-gitea-theme-dark="true"], -html[data-gitea-theme-dark="true"] .swagger-ui, -html[data-gitea-theme-dark="true"] .swagger-ui .scheme-container { +html.dark-mode body, +html.dark-mode .swagger-ui, +html.dark-mode .swagger-ui .scheme-container { background: var(--color-box-body) !important; } +html.dark-mode .swagger-ui table.headers td { + color: var(--color-text) !important; +} + .swagger-back-link { color: var(--color-primary); text-decoration: none; @@ -25,10 +29,6 @@ html[data-gitea-theme-dark="true"] .swagger-ui .scheme-container { align-items: center; } -html[data-gitea-theme-dark="true"] .swagger-ui table.headers td { - color: var(--color-text) !important; -} - .swagger-back-link:hover { text-decoration: underline; } diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 531942e0b1ab8..1197294eb711e 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -1,5 +1,4 @@ import {generateElemId, queryElemChildren} from '../utils/dom.ts'; -import {isDarkTheme} from '../utils.ts'; function safeRenderIframeLink(link: any): string | null { try { @@ -38,7 +37,7 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { if (!e.data?.giteaIframeCmd || e.data?.giteaIframeId !== iframe.id) return; const cmd = e.data.giteaIframeCmd; if (cmd === 'resize') { - iframe.style.height = `${e.data.iframeHeight}px`; + if (!iframe.classList.contains('is-loading')) iframe.style.height = `${e.data.iframeHeight}px`; } else if (cmd === 'open-link') { navigateToIframeLink(e.data.openLink, e.data.anchorTarget); } else { @@ -46,8 +45,20 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { } }); + iframe.addEventListener('load', () => { + try { + // copy theme CSS vars from parent to iframe so var(--color-box-body) etc. resolve correctly + const parentStyle = getComputedStyle(document.documentElement); + const iframeRoot = iframe.contentDocument!.documentElement; + for (const prop of ['--color-box-body', '--is-dark-theme']) { + const value = parentStyle.getPropertyValue(prop).trim(); + if (value) iframeRoot.style.setProperty(prop, value); + } + } catch { /* cross-origin — ignore */ } + iframe.classList.remove('is-loading'); + }); + const u = new URL(iframeSrcUrl, window.location.origin); - u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme())); u.searchParams.set('gitea-iframe-id', iframe.id); iframe.src = u.href; } diff --git a/web_src/js/modules/external-render-iframe.ts b/web_src/js/modules/external-render-iframe.ts index 439be25856f9f..260e1066a6ad6 100644 --- a/web_src/js/modules/external-render-iframe.ts +++ b/web_src/js/modules/external-render-iframe.ts @@ -22,10 +22,10 @@ export function initExternalRenderIframe() { }; const updateIframeHeight = () => { - // Don't use integer heights from the DOM node. - // Use getBoundingClientRect(), then ceil the height to avoid fractional pixels which causes incorrect scrollbars. - const rect = document.documentElement.getBoundingClientRect(); - postIframeMsg('resize', {iframeHeight: Math.ceil(rect.height)}); + // Use scrollHeight to get the full content height, even when CSS sets html/body to height:100% + // (which would make getBoundingClientRect return the viewport height instead of content height). + const height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); + postIframeMsg('resize', {iframeHeight: height}); // As long as the parent page is responsible for the iframe height, the iframe itself doesn't need scrollbars. // This style should only be dynamically set here when our code can run. document.documentElement.style.overflowY = 'hidden'; From 0d0a5488158d5c8e9affee8a5b71139ee0b26f45 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:33:16 +0200 Subject: [PATCH 08/29] Rename standalone/ and modules/ to pages/ Move page-specific init scripts to web_src/js/pages/ and their CSS to web_src/css/pages/. Remove the standalone/ directory. Co-Authored-By: Claude (Opus 4.6) --- modules/public/vitedev.go | 2 +- vite.config.ts | 2 +- .../css/{standalone => pages}/external-render-iframe.css | 0 web_src/js/index.ts | 4 ++-- web_src/js/{modules => pages}/devtest.ts | 4 ++-- web_src/js/{modules => pages}/external-render-iframe.ts | 8 ++++---- web_src/js/{modules => pages}/swagger.ts | 0 web_src/js/standalone/external-render-iframe.ts | 4 ---- 8 files changed, 10 insertions(+), 14 deletions(-) rename web_src/css/{standalone => pages}/external-render-iframe.css (100%) rename web_src/js/{modules => pages}/devtest.ts (89%) rename web_src/js/{modules => pages}/external-render-iframe.ts (93%) rename web_src/js/{modules => pages}/swagger.ts (100%) delete mode 100644 web_src/js/standalone/external-render-iframe.ts diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 211783b6bb26e..f9fcafc8108a7 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -165,7 +165,7 @@ func viteDevSourceURL(name string) string { case "js/index.js": return setting.AppSubURL + "/web_src/js/index.ts" case "js/external-render-iframe.js": - return setting.AppSubURL + "/web_src/js/standalone/external-render-iframe.ts" + return setting.AppSubURL + "/web_src/js/pages/external-render-iframe.ts" } webSrcDir := filepath.Join(setting.StaticRootPath, "web_src") srcPath := filepath.Join(webSrcDir, name) diff --git a/vite.config.ts b/vite.config.ts index a277f513def2d..12b484d551ff1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -245,7 +245,7 @@ export default defineConfig(commonViteOpts({ rolldownOptions: { input: { index: join(import.meta.dirname, 'web_src/js/index.ts'), - 'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'), + 'external-render-iframe': join(import.meta.dirname, 'web_src/js/pages/external-render-iframe.ts'), 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'), swagger: join(import.meta.dirname, 'web_src/css/swagger.css'), devtest: join(import.meta.dirname, 'web_src/css/devtest.css'), diff --git a/web_src/css/standalone/external-render-iframe.css b/web_src/css/pages/external-render-iframe.css similarity index 100% rename from web_src/css/standalone/external-render-iframe.css rename to web_src/css/pages/external-render-iframe.css diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 69362a2881f2e..bd850c65d2ee4 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -67,8 +67,8 @@ import {callInitFunctions} from './modules/init.ts'; import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; import {initActionsPermissionsForm} from './features/common-actions-permissions.ts'; import {initGlobalShortcut} from './modules/shortcut.ts'; -import {initSwagger} from './modules/swagger.ts'; -import {initDevtest} from './modules/devtest.ts'; +import {initSwagger} from './pages/swagger.ts'; +import {initDevtest} from './pages/devtest.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ diff --git a/web_src/js/modules/devtest.ts b/web_src/js/pages/devtest.ts similarity index 89% rename from web_src/js/modules/devtest.ts rename to web_src/js/pages/devtest.ts index 67aeb980039e3..e29948c304670 100644 --- a/web_src/js/modules/devtest.ts +++ b/web_src/js/pages/devtest.ts @@ -1,5 +1,5 @@ -import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts'; -import type {Toast} from './toast.ts'; +import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.ts'; +import type {Toast} from '../modules/toast.ts'; type LevelMap = Record Toast | null>; diff --git a/web_src/js/modules/external-render-iframe.ts b/web_src/js/pages/external-render-iframe.ts similarity index 93% rename from web_src/js/modules/external-render-iframe.ts rename to web_src/js/pages/external-render-iframe.ts index 260e1066a6ad6..b0dea37f1519b 100644 --- a/web_src/js/modules/external-render-iframe.ts +++ b/web_src/js/pages/external-render-iframe.ts @@ -11,11 +11,11 @@ RENDER_COMMAND = `echo '
Date: Fri, 3 Apr 2026 23:38:09 +0200 Subject: [PATCH 10/29] Remove redundant ErrAbortHandler recover in vitedev proxy The ErrorHandler context check already handles client disconnects. Co-Authored-By: Claude (Opus 4.6) --- modules/public/vitedev.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index f9fcafc8108a7..d42ac2ad1c9da 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -71,7 +71,7 @@ func getViteDevProxy() *httputil.ReverseProxy { }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { if r.Context().Err() != nil { - return // client disconnected, silently ignore + 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) @@ -97,11 +97,6 @@ func ViteDevMiddleware(next http.Handler) http.Handler { return } routing.MarkLongPolling(resp, req) - defer func() { - if r := recover(); r != nil && r != http.ErrAbortHandler { - panic(r) - } - }() proxy.ServeHTTP(resp, req) }) } From 567eccc11cd76ee331d4f0ea24692ab367421bfd Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:39:47 +0200 Subject: [PATCH 11/29] remove lint exclusion --- stylelint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stylelint.config.js b/stylelint.config.js index 3e6be3c24872d..0aee1a5dac2d1 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -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, }, From 090f02af053f50e66108ba3322461960550be0f4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:40:45 +0200 Subject: [PATCH 12/29] Restore gitea-is-dark-theme URL parameter on iframe Co-Authored-By: Claude (Opus 4.6) --- web_src/js/markup/render-iframe.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 1197294eb711e..b7fd3311924ee 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -1,4 +1,5 @@ import {generateElemId, queryElemChildren} from '../utils/dom.ts'; +import {isDarkTheme} from '../utils.ts'; function safeRenderIframeLink(link: any): string | null { try { @@ -59,6 +60,7 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { }); const u = new URL(iframeSrcUrl, window.location.origin); + u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme())); u.searchParams.set('gitea-iframe-id', iframe.id); iframe.src = u.href; } From 76fc400c261caacbc2a7419f117e819213dda4ac Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:43:00 +0200 Subject: [PATCH 13/29] Remove dead jQuery check from devtest header No longer needed since devtest now loads via index.js which includes jQuery through iife.js. Co-Authored-By: Claude (Opus 4.6) --- templates/devtest/devtest-header.tmpl | 5 ----- 1 file changed, 5 deletions(-) diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl index a7aebcb7dc825..1bb0e6d9882ec 100644 --- a/templates/devtest/devtest-header.tmpl +++ b/templates/devtest/devtest-header.tmpl @@ -1,8 +1,3 @@ {{template "base/head" ctx.RootData}} - {{template "base/alert" .}} From 307d38df8e08878b32549184082ca2e42d665a34 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:44:26 +0200 Subject: [PATCH 14/29] Remove unnecessary comment in openapi.go Co-Authored-By: Claude (Opus 4.6) --- modules/markup/external/openapi.go | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index d03609257828e..ddb202771bec7 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -57,7 +57,6 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out return err } // TODO: can extract this to a tmpl file later - // external-render-iframe.js is additionally prepended by RenderWithRenderer via extraHeadHTML _, err = io.WriteString(output, fmt.Sprintf( ` From 501470d93e38b5decf056462540173415824cf07 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:54:04 +0200 Subject: [PATCH 15/29] Restore HeadScriptHTML, remove manual window.config stub Use pre-rendered head_script template from the router instead of a fragile manual window.config stub in openapi.go. Theme CSS vars are copied from parent to iframe on load by render-iframe.ts. Co-Authored-By: Claude (Opus 4.6) --- modules/markup/external/openapi.go | 7 ++----- modules/markup/render.go | 8 ++++++++ routers/web/repo/render.go | 4 +++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index ddb202771bec7..33ffdcdfbfc30 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -62,8 +62,7 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out - - + %s @@ -71,9 +70,7 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out `, - setting.AppSubURL, - setting.StaticURLPrefix+"/assets", - public.AssetURI("js/iife.js"), + ctx.RenderOptions.HeadScriptHTML, public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), diff --git a/modules/markup/render.go b/modules/markup/render.go index 991256acfc285..ff461586f2882 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -57,6 +57,9 @@ type RenderOptions struct { // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page InStandalonePage bool + // HeadScriptHTML is pre-rendered HTML from base/head_script template, used by openapi renderer + HeadScriptHTML template.HTML + // 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. EnableHeadingIDGeneration bool @@ -132,6 +135,11 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext { return ctx } +func (ctx *RenderContext) WithHeadScriptHTML(v template.HTML) *RenderContext { + ctx.RenderOptions.HeadScriptHTML = v + return ctx +} + func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext { ctx.RenderOptions.EnableHeadingIDGeneration = v return ctx diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index b1299c7047e49..4ef7be9bf99be 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -39,10 +39,12 @@ func RenderFile(ctx *context.Context) { } defer blobReader.Close() + headScriptHTML, _ := ctx.RenderToHTML("base/head_script", ctx.Data) + 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).WithInStandalonePage(true).WithHeadScriptHTML(headScriptHTML) renderer, rendererInput, err := rctx.DetectMarkupRendererByReader(blobReader) if err != nil { http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest) From 01a7f09190c2215d6cae3b9f27ce05393d16ac5b Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 3 Apr 2026 23:59:50 +0200 Subject: [PATCH 16/29] Fix missing CSS var copy, log RenderToHTML error, parallelize swagger imports - Copy --color-text to iframe for dark mode table headers - Log error from ctx.RenderToHTML instead of ignoring it - Use Promise.all for parallel swagger CSS/JS dynamic imports Co-Authored-By: Claude (Opus 4.6) --- routers/web/repo/render.go | 5 ++++- web_src/js/markup/render-iframe.ts | 2 +- web_src/js/pages/swagger.ts | 8 +++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index 4ef7be9bf99be..8f04a855b65c0 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -39,7 +39,10 @@ func RenderFile(ctx *context.Context) { } defer blobReader.Close() - headScriptHTML, _ := ctx.RenderToHTML("base/head_script", ctx.Data) + headScriptHTML, err := ctx.RenderToHTML("base/head_script", ctx.Data) + if err != nil { + log.Error("RenderToHTML head_script: %v", err) + } rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index b7fd3311924ee..c47e4689ab752 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -51,7 +51,7 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { // copy theme CSS vars from parent to iframe so var(--color-box-body) etc. resolve correctly const parentStyle = getComputedStyle(document.documentElement); const iframeRoot = iframe.contentDocument!.documentElement; - for (const prop of ['--color-box-body', '--is-dark-theme']) { + for (const prop of ['--color-box-body', '--color-text', '--is-dark-theme']) { const value = parentStyle.getPropertyValue(prop).trim(); if (value) iframeRoot.style.setProperty(prop, value); } diff --git a/web_src/js/pages/swagger.ts b/web_src/js/pages/swagger.ts index 5f690f1770330..da3e6ff69ca92 100644 --- a/web_src/js/pages/swagger.ts +++ b/web_src/js/pages/swagger.ts @@ -13,9 +13,11 @@ export async function initSwagger() { syncDarkModeClass(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); - const {default: SwaggerUI} = await import('swagger-ui-dist/swagger-ui-es-bundle.js'); - await import('swagger-ui-dist/swagger-ui.css'); - await import('../../css/swagger.css'); + const [{default: SwaggerUI}] = await Promise.all([ + import('swagger-ui-dist/swagger-ui-es-bundle.js'), + import('swagger-ui-dist/swagger-ui.css'), + import('../../css/swagger.css'), + ]); const url = elSwaggerUi.getAttribute('data-source')!; let spec: any; From 802a682b22cedef76ac51e67ad768d556aeb4e0f Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 4 Apr 2026 00:00:31 +0200 Subject: [PATCH 17/29] Revert parallel swagger imports to preserve CSS cascade order Sequential imports guarantee swagger-ui.css loads before swagger.css, which is needed for correct cascade without relying on !important. Co-Authored-By: Claude (Opus 4.6) --- web_src/js/pages/swagger.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web_src/js/pages/swagger.ts b/web_src/js/pages/swagger.ts index da3e6ff69ca92..5f690f1770330 100644 --- a/web_src/js/pages/swagger.ts +++ b/web_src/js/pages/swagger.ts @@ -13,11 +13,9 @@ export async function initSwagger() { syncDarkModeClass(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); - const [{default: SwaggerUI}] = await Promise.all([ - import('swagger-ui-dist/swagger-ui-es-bundle.js'), - import('swagger-ui-dist/swagger-ui.css'), - import('../../css/swagger.css'), - ]); + const {default: SwaggerUI} = await import('swagger-ui-dist/swagger-ui-es-bundle.js'); + await import('swagger-ui-dist/swagger-ui.css'); + await import('../../css/swagger.css'); const url = elSwaggerUi.getAttribute('data-source')!; let spec: any; From 8eb112755a2a8bb4ccd13d530c906485425be218 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 4 Apr 2026 00:01:30 +0200 Subject: [PATCH 18/29] Import swagger-ui CSS from swagger.css, parallelize JS/CSS imports Move swagger-ui.css @import into swagger.css to guarantee cascade order via CSS itself. This allows parallel dynamic imports in JS. Co-Authored-By: Claude (Opus 4.6) --- web_src/css/swagger.css | 2 ++ web_src/js/pages/swagger.ts | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web_src/css/swagger.css b/web_src/css/swagger.css index 72a3c507edd01..db4c2b3098151 100644 --- a/web_src/css/swagger.css +++ b/web_src/css/swagger.css @@ -1,3 +1,5 @@ +@import "swagger-ui-dist/swagger-ui.css"; + *, *::before, *::after { diff --git a/web_src/js/pages/swagger.ts b/web_src/js/pages/swagger.ts index 5f690f1770330..589eadee60f6d 100644 --- a/web_src/js/pages/swagger.ts +++ b/web_src/js/pages/swagger.ts @@ -13,9 +13,10 @@ export async function initSwagger() { syncDarkModeClass(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); - const {default: SwaggerUI} = await import('swagger-ui-dist/swagger-ui-es-bundle.js'); - await import('swagger-ui-dist/swagger-ui.css'); - await import('../../css/swagger.css'); + const [{default: SwaggerUI}] = await Promise.all([ + import('swagger-ui-dist/swagger-ui-es-bundle.js'), + import('../../css/swagger.css'), + ]); const url = elSwaggerUi.getAttribute('data-source')!; let spec: any; From 110f65fd9286a3d350988cfd6283cb1dc9682eb3 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Apr 2026 12:24:05 +0800 Subject: [PATCH 19/29] refactor --- modules/markup/external/openapi.go | 14 +++-- modules/markup/render.go | 40 ++++++------ modules/public/public.go | 11 ++++ modules/public/vitedev.go | 43 ++++++------- routers/web/misc/swagger.go | 8 +-- routers/web/repo/render.go | 9 +-- routers/web/web.go | 2 +- services/webtheme/webtheme.go | 5 ++ templates/base/head_style.tmpl | 2 +- templates/devtest/devtest-header.tmpl | 1 + .../swagger/{ui.tmpl => openapi-viewer.tmpl} | 7 +-- vite.config.ts | 61 +++++++++++-------- web_src/css/markup/content.css | 4 -- web_src/css/pages/external-render-iframe.css | 1 - web_src/css/{ => standalone}/swagger.css | 12 ++-- web_src/js/index.ts | 4 +- web_src/js/markup/render-iframe.ts | 30 ++++----- web_src/js/modules/devtest.ts | 20 ++++++ web_src/js/pages/devtest.ts | 17 ------ .../external-render-iframe.ts | 31 +++++++++- web_src/js/{pages => standalone}/swagger.ts | 25 ++++---- web_src/js/utils.ts | 5 +- 22 files changed, 196 insertions(+), 156 deletions(-) rename templates/swagger/{ui.tmpl => openapi-viewer.tmpl} (59%) delete mode 100644 web_src/css/pages/external-render-iframe.css rename web_src/css/{ => standalone}/swagger.css (70%) create mode 100644 web_src/js/modules/devtest.ts delete mode 100644 web_src/js/pages/devtest.ts rename web_src/js/{pages => standalone}/external-render-iframe.ts (73%) rename web_src/js/{pages => standalone}/swagger.ts (61%) diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index 33ffdcdfbfc30..d3b06f3c6c2ed 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -47,22 +47,26 @@ 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.ContentSandbox, output) + } + 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( ` - %s + @@ -70,11 +74,11 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out `, - ctx.RenderOptions.HeadScriptHTML, + ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI(), public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), - public.AssetURI("js/index.js"), + public.AssetURI("js/swagger.js"), )) return err } diff --git a/modules/markup/render.go b/modules/markup/render.go index ff461586f2882..6230def178aa3 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -38,6 +38,14 @@ var RenderBehaviorForTesting struct { DisableAdditionalAttributes bool } +type WebThemeInterface interface { + PublicAssetURI() string +} + +type StandalonePageOptions struct { + CurrentWebTheme WebThemeInterface +} + type RenderOptions struct { UseAbsoluteLink bool @@ -55,10 +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 - - // HeadScriptHTML is pre-rendered HTML from base/head_script template, used by openapi renderer - HeadScriptHTML template.HTML + 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. @@ -130,13 +135,8 @@ func (ctx *RenderContext) WithMetas(metas map[string]string) *RenderContext { return ctx } -func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext { - ctx.RenderOptions.InStandalonePage = v - return ctx -} - -func (ctx *RenderContext) WithHeadScriptHTML(v template.HTML) *RenderContext { - ctx.RenderOptions.HeadScriptHTML = v +func (ctx *RenderContext) WithStandalonePage(opts StandalonePageOptions) *RenderContext { + ctx.RenderOptions.StandalonePageOptions = &opts return ctx } @@ -205,20 +205,14 @@ 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, sandbox string, 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) - } - iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) - _, err := io.WriteString(output, string(iframe)) + _, err := htmlutil.HTMLPrintf(output, ``, src, sandbox) return err } @@ -240,16 +234,16 @@ 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 `, src, sandbox) + var extraAttrs template.HTML + if opts.ContentSandbox != "" { + extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox) + } + _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) return err } @@ -237,7 +241,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, if ctx.RenderOptions.StandalonePageOptions == nil { // for an external "DisplayInIFrame" render, it could only output its content in a standalone page // otherwise, a `, ret) + + ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"}) + assert.Equal(t, ``, ret) +} From d27ad932fc344366d8c7e25ee252edacf3e9f473 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 6 Apr 2026 00:01:36 +0800 Subject: [PATCH 28/29] simplify external render css injection --- modules/markup/external/openapi.go | 2 -- modules/markup/render.go | 3 ++- templates/base/head_style.tmpl | 2 +- web_src/js/external-render-helper.ts | 9 --------- web_src/js/markup/render-iframe.ts | 2 -- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index e2fd4576f8b50..91230e54d02f9 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -69,14 +69,12 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out -
`, - ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI(), public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), diff --git a/modules/markup/render.go b/modules/markup/render.go index 1f79ad6f3a7f9..caed3428e0b77 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -245,9 +245,10 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, } // else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS extraScriptSrc := public.AssetURI("js/external-render-helper.js") + extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI() // "`, extraScriptSrc) + extraHeadHTML = htmlutil.HTMLFormat(``, extraScriptSrc, extraLinkHref) } ctx.usedByRender = true diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl index 416f043baff09..4a4fb9d96f8b7 100644 --- a/templates/base/head_style.tmpl +++ b/templates/base/head_style.tmpl @@ -1,2 +1,2 @@ - + diff --git a/web_src/js/external-render-helper.ts b/web_src/js/external-render-helper.ts index 66dafe895d1a6..3acac8db14117 100644 --- a/web_src/js/external-render-helper.ts +++ b/web_src/js/external-render-helper.ts @@ -22,15 +22,6 @@ if (isDarkTheme) { document.documentElement.setAttribute('data-gitea-theme-dark', String(isDarkTheme)); } -const themeUri = url.searchParams.get('gitea-theme-uri'); -if (themeUri) { - const elLinkStyle = document.createElement('link'); - elLinkStyle.id = 'current-web-theme-style'; - elLinkStyle.rel = 'stylesheet'; - elLinkStyle.href = themeUri; - document.head.append(elLinkStyle); -} - const backgroundColor = url.searchParams.get('gitea-iframe-bgcolor'); if (backgroundColor) { // create a style element to set background color, then it can be overridden by the content page's own style if needed diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 66dafd6d16222..09493df780280 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -58,11 +58,9 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { } }); - const elLinkStyle = document.querySelector('link#current-web-theme-style')!; const u = new URL(iframeSrcUrl, window.location.origin); u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme())); u.searchParams.set('gitea-iframe-id', iframe.id); - u.searchParams.set('gitea-theme-uri', elLinkStyle.href); u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe)); iframe.src = u.href; } From fad65271dffcf258ebb3136b9224bafe61f8c2ac Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 6 Apr 2026 02:17:28 +0800 Subject: [PATCH 29/29] fix test --- tests/integration/markup_external_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index b9f4cbc583c64..8baa266962db2 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -108,7 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) { // default sandbox in sub page response assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "
<script></script>
`, respSub.Body.String()) + assert.Equal(t, `
<script></script>
`, respSub.Body.String()) }) }) @@ -131,7 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) { t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") respSub := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, ``, respSub.Body.String()) + assert.Equal(t, ``, respSub.Body.String()) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) }) })