Merge some standalone Vite entries into index.js (#37085)

Keep `swagger` and `external-render-helper` as a standalone entries for
external render.

- Move `devtest.ts` to `modules/` as init functions
- Make external renders correctly load its helper JS and Gitea's current theme
- Make external render iframe inherit Gitea's iframe's background color to avoid flicker
- Add e2e tests for external render and OpenAPI iframe

---------

Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-04-05 21:13:34 +02:00
committed by GitHub
parent 5f443184f3
commit a8938115d4
35 changed files with 419 additions and 247 deletions

View File

@@ -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>

View File

@@ -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,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.
@@ -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
}
@@ -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
}
@@ -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

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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"])

View File

@@ -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 {
@@ -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()

View File

@@ -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"
)
@@ -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)
},
@@ -136,34 +140,33 @@ func IsViteDevMode() bool {
return isDev
}
func viteDevSourceURL(name string) string {
if !IsViteDevMode() {
return ""
}
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
}
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"
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/
themeFilePath := "css/themes/" + strings.TrimPrefix(name, "css/")
if srcPath := detectWebSrcPath(themeFilePath); srcPath != "" {
return srcPath
}
}
// try to map ".js" files to ".ts" files
pathPrefix, ok := strings.CutSuffix(name, ".js")
if ok {
if srcPath := detectWebSrcPath(pathPrefix + ".ts"); srcPath != "" {
return srcPath
}
}
// for all others that the names match
return detectWebSrcPath(name)
}
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
// Ref: Vite source packages/vite/src/node/constants.ts and packages/vite/src/shared/constants.ts
func isViteDevRequest(req *http.Request) bool {

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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"))

View File

@@ -5,6 +5,7 @@ package webtheme
import (
"io/fs"
"net/url"
"os"
"path"
"regexp"
@@ -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"

View File

@@ -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,
},

View File

@@ -1,11 +1,8 @@
import {readFileSync} from 'node:fs';
import {env} from 'node:process';
import {parse} from 'postcss';
import plugin from 'tailwindcss/plugin.js';
import type {Config} from 'tailwindcss';
const isProduction = env.NODE_ENV !== 'development';
function extractRootVars(css: string) {
const root = parse(css);
const vars = new Set<string>();
@@ -29,8 +26,6 @@ export default {
prefix: 'tw-',
important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles
content: [
isProduction && '!./templates/devtest/**/*',
isProduction && '!./web_src/js/standalone/devtest.ts',
'!./templates/swagger/v1_json.tmpl',
'!./templates/user/auth/oidc_wellknown.tmpl',
'!**/*_test.go',

View File

@@ -1,2 +1,2 @@
<link rel="stylesheet" href="{{AssetURI "css/index.css"}}">
<link rel="stylesheet" href="{{AssetURI (printf "css/theme-%s.css" (PathEscape ctx.CurrentWebTheme.InternalName))}}">
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">

View File

@@ -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 */}}
<script type="module" src="{{AssetURI "js/devtest.js"}}"></script>
{{template "base/footer" ctx.RootData}}

View File

@@ -1,8 +1,4 @@
{{template "base/head" ctx.RootData}}
<link rel="stylesheet" href="{{AssetURI "css/devtest.css"}}">
<script>
// must make sure the jQuery is globally loaded
// ref: https://github.com/go-gitea/gitea/issues/35923
if (!window.jQuery) alert('jQuery is missing, user custom plugins may not work');
</script>
<div class="tw-hidden" data-global-init="initDevtestPage"></div>
{{template "base/alert" .}}

View File

@@ -2,13 +2,15 @@
<html lang="en">
<head>
<title>Gitea API</title>
<link href="{{AssetURI "css/swagger.css"}}" rel="stylesheet">
{{/* HINT: SWAGGER-OPENAPI-VIEWER: another place is "modules/markup/external/openapi.go" */}}
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">
<link rel="stylesheet" href="{{AssetURI "css/swagger.css"}}">
</head>
<body>
{{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}}
<a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.{{.APIJSONVersion}}.json"></div>
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.v1.json"></div>
<footer class="page-footer"></footer>
<script type="module" src="{{AssetURI "js/swagger.js"}}"></script>
{{ScriptImport "js/swagger.js" "module"}}
</body>
</html>

View File

@@ -0,0 +1,45 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertNoJsError, randomString} from './utils.ts';
test('external file', 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', '<p>rendered content</p>');
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`));
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', 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();
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);
}
});

View File

@@ -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(),

View File

@@ -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>" tag, but it indeed is the sanitizer's job
assert.Equal(t, `<script type="module" src="`+public.AssetURI("js/external-render-iframe.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/external-render-iframe.css")+`"><div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`, respSub.Body.String())
assert.Equal(t, `<script crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`"><div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`, 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, `<script type="module" src="`+public.AssetURI("js/external-render-iframe.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/external-render-iframe.css")+`"><script>foo("raw")</script>`, respSub.Body.String())
assert.Equal(t, `<script crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`"><script>foo("raw")</script>`, respSub.Body.String())
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
})
})

View File

@@ -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"

View File

@@ -2,13 +2,14 @@ import {build, defineConfig} from 'vite';
import vuePlugin from '@vitejs/plugin-vue';
import {stringPlugin} from 'vite-string-plugin';
import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs';
import path, {join, parse} from 'node:path';
import path, {basename, join, parse} from 'node:path';
import {env} from 'node:process';
import tailwindcss from 'tailwindcss';
import tailwindConfig from './tailwind.config.ts';
import wrapAnsi from 'wrap-ansi';
import licensePlugin from 'rollup-plugin-license';
import type {InlineConfig, Plugin, Rolldown} from 'vite';
import {camelize} from 'vue';
const isProduction = env.NODE_ENV !== 'development';
@@ -76,13 +77,14 @@ function commonViteOpts({build, ...other}: InlineConfig): InlineConfig {
};
}
const iifeEntry = join(import.meta.dirname, 'web_src/js/iife.ts');
function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: boolean}) {
function iifeBuildOpts({sourceFileName, write}: {sourceFileName: string, write?: boolean}) {
const sourceBaseName = basename(sourceFileName, '.ts');
// HINT: VITE-OUTPUT-DIR: all outputted JS files are in "js" directory
const entryFileName = `js/${sourceBaseName}.[hash:8].js`;
return commonViteOpts({
build: {
lib: {entry: iifeEntry, formats: ['iife'], name: 'iife'},
rolldownOptions: {output: {entryFileNames}},
lib: {entry: join(import.meta.dirname, 'web_src/js', sourceFileName), name: camelize(sourceBaseName), formats: ['iife']},
rolldownOptions: {output: {entryFileNames: entryFileName}},
...(write === false && {write: false}),
},
plugins: [stringPlugin()],
@@ -91,19 +93,20 @@ function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?:
// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory
// and rebuilds on file changes. In prod mode, writes to disk during closeBundle.
function iifePlugin(): Plugin {
let iifeCode = '';
let iifeMap = '';
function iifePlugin(sourceFileName: string): Plugin {
let iifeCode = '', iifeMap = '';
const iifeModules = new Set<string>();
let isBuilding = false;
const sourceBaseName = path.basename(sourceFileName, '.ts');
return {
name: 'iife',
name: `iife:${sourceFileName}`, // plugin name
async configureServer(server) {
const buildAndCache = async () => {
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.js', write: false}));
const result = await build(iifeBuildOpts({sourceFileName, write: false}));
const output = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
const chunk = output.output[0];
iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, '//# sourceMappingURL=__vite_iife.js.map');
iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, `//# sourceMappingURL=${sourceBaseName}.js.map`);
const mapAsset = output.output.find((o) => o.fileName.endsWith('.map'));
iifeMap = mapAsset && 'source' in mapAsset ? String(mapAsset.source) : '';
iifeModules.clear();
@@ -129,15 +132,15 @@ function iifePlugin(): Plugin {
});
server.middlewares.use((req, res, next) => {
// "__vite_iife" is a virtual file in memory, serve it directly
// on the dev server, an "iife" file is a virtual file in memory, serve it directly
const pathname = req.url!.split('?')[0];
if (pathname === '/web_src/js/__vite_dev_server_check') {
res.end('ok');
} else if (pathname === '/web_src/js/__vite_iife.js') {
} else if (pathname === `/web_src/js/${sourceFileName}`) {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-store');
res.end(iifeCode);
} else if (pathname === '/web_src/js/__vite_iife.js.map') {
} else if (pathname === `/web_src/js/${sourceBaseName}.js.map`) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(iifeMap);
@@ -147,29 +150,38 @@ function iifePlugin(): Plugin {
});
},
async closeBundle() {
for (const file of globSync('js/iife.*.js*', {cwd: outDir})) unlinkSync(join(outDir, file));
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.[hash:8].js'}));
for (const file of globSync(`js/${sourceBaseName}.*.js*`, {cwd: outDir})) unlinkSync(join(outDir, file));
const result = await build(iifeBuildOpts({sourceFileName}));
const buildOutput = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
const entry = buildOutput.output.find((o) => o.fileName.startsWith('js/iife.'));
const entry = buildOutput.output.find((o) => o.fileName.startsWith(`js/${sourceBaseName}.`));
if (!entry) throw new Error('IIFE build produced no output');
const manifestPath = join(outDir, '.vite', 'manifest.json');
writeFileSync(manifestPath, JSON.stringify({
...JSON.parse(readFileSync(manifestPath, 'utf8')),
'web_src/js/iife.ts': {file: entry.fileName, name: 'iife', isEntry: true},
}, null, 2));
const manifestData = JSON.parse(readFileSync(manifestPath, 'utf8'));
manifestData[`web_src/js/${sourceFileName}`] = {file: entry.fileName, name: sourceBaseName, isEntry: true};
writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2));
},
};
}
// In reduced sourcemap mode, only keep sourcemaps for main files
function reducedSourcemapPlugin(): Plugin {
const standalonePrefixes = [
'js/index.',
'js/iife.',
'js/swagger.',
'js/external-render-helper.',
'js/eventsource.sharedworker.',
];
return {
name: 'reduced-sourcemap',
apply: 'build',
closeBundle() {
if (enableSourcemap !== 'reduced') return;
for (const file of globSync('{js,css}/*.map', {cwd: outDir})) {
if (!file.startsWith('js/index.') && !file.startsWith('js/iife.')) unlinkSync(join(outDir, file));
if (standalonePrefixes.some((prefix) => file.startsWith(prefix))) continue;
unlinkSync(join(outDir, file));
}
},
};
@@ -215,6 +227,7 @@ export default defineConfig(commonViteOpts({
open: false,
host: '0.0.0.0',
strictPort: false,
cors: true,
fs: {
// VITE-DEV-SERVER-SECURITY: the dev server will be exposed to public by Gitea's web server, so we need to strictly limit the access
// Otherwise `/@fs/*` will be able to access any file (including app.ini which contains INTERNAL_TOKEN)
@@ -245,15 +258,15 @@ 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/js/swagger.ts'),
'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/eventsource.sharedworker.ts'),
devtest: join(import.meta.dirname, 'web_src/css/devtest.css'),
...themes,
},
output: {
// HINT: VITE-OUTPUT-DIR: all outputted JS files are in "js" directory
// So standalone/iife source files should also be in "js" directory,
// to keep consistent between production and dev server, avoid unexpected behaviors.
entryFileNames: 'js/[name].[hash:8].js',
chunkFileNames: 'js/[name].[hash:8].js',
assetFileNames: ({names}) => {
@@ -287,7 +300,8 @@ export default defineConfig(commonViteOpts({
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
},
plugins: [
iifePlugin(),
iifePlugin('iife.ts'),
iifePlugin('external-render-helper.ts'),
viteDevServerPortPlugin(),
reducedSourcemapPlugin(),
filterCssUrlPlugin(),

View File

@@ -529,6 +529,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
.external-render-iframe {
width: 100%;
height: max(300px, 80vh);
border: none;
}
.markup-content-iframe {

View File

@@ -1 +0,0 @@
/* dummy */

View File

@@ -1,46 +0,0 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: #fff;
}
.swagger-back-link {
color: #4990e2;
text-decoration: none;
position: absolute;
top: 1rem;
right: 1.5rem;
display: flex;
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 */
}
}
.swagger-back-link:hover {
text-decoration: underline;
}
.swagger-back-link svg {
color: inherit;
fill: currentcolor;
margin-right: 0.5rem;
}
.swagger-spec-content {
display: none;
}

41
web_src/css/swagger.css Normal file
View File

@@ -0,0 +1,41 @@
@import "../../node_modules/swagger-ui-dist/swagger-ui.css";
body {
margin: 0;
}
html,
html body,
html .swagger-ui,
html .swagger-ui .scheme-container {
background: var(--gitea-iframe-bgcolor, var(--color-box-body)) !important;
}
/* swagger's bug: the selector was incorrectly written in "thead": "html.dark-mode .swagger-ui .opblock.opblock-get thead tr td" */
html.dark-mode .swagger-ui table.headers td {
color: var(--color-text) !important;
}
.swagger-back-link {
color: var(--color-primary);
text-decoration: none;
position: absolute;
top: 1rem;
right: 1.5rem;
display: flex;
align-items: center;
}
.swagger-back-link:hover {
text-decoration: underline;
}
.swagger-back-link svg {
color: inherit;
fill: currentcolor;
margin-right: 0.5rem;
}
.swagger-spec-content {
display: none;
}

View File

@@ -1,3 +1,7 @@
// External render JS must be a IIFE module to run as early as possible to set up the environment for the content page.
// Avoid unnecessary dependency.
// Do NOT introduce global pollution, because the content page should be fully controlled by the external render.
/* To manually test:
[markup.in-iframe]
@@ -11,22 +15,39 @@ RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px so
*/
import '../../css/standalone/external-render-iframe.css';
const url = new URL(window.location.href);
function mainExternalRenderIframe() {
const u = new URL(window.location.href);
const iframeId = u.searchParams.get('gitea-iframe-id');
const isDarkTheme = url.searchParams.get('gitea-is-dark-theme') === 'true';
if (isDarkTheme) {
document.documentElement.setAttribute('data-gitea-theme-dark', String(isDarkTheme));
}
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
const style = document.createElement('style');
style.textContent = `
:root {
--gitea-iframe-bgcolor: ${backgroundColor};
}
body { background: ${backgroundColor}; }
`;
document.head.append(style);
}
const iframeId = url.searchParams.get('gitea-iframe-id');
if (iframeId) {
// iframe is in different origin, so we need to use postMessage to communicate
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
};
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)});
if (!document.body) return; // the body might not be available when this function is called
// 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';
@@ -54,5 +75,3 @@ function mainExternalRenderIframe() {
}
});
}
mainExternalRenderIframe();

View File

@@ -67,6 +67,7 @@ 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 {initDevtest} from './modules/devtest.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
@@ -160,6 +161,8 @@ const initPerformanceTracer = callInitFunctions([
initRepoFileView,
initActionsPermissionsForm,
initDevtest,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.

View File

@@ -29,6 +29,18 @@ export function navigateToIframeLink(unsafeLink: any, target: any) {
window.location.assign(linkHref);
}
function getRealBackgroundColor(el: HTMLElement) {
for (let n = el; n; n = n.parentElement!) {
const style = window.getComputedStyle(n);
const bgColor = style.backgroundColor;
// 'rgba(0, 0, 0, 0)' is how most browsers represent transparent
if (bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
return bgColor;
}
}
return '';
}
async function loadRenderIframeContent(iframe: HTMLIFrameElement) {
const iframeSrcUrl = iframe.getAttribute('data-src')!;
if (!iframe.id) iframe.id = generateElemId('gitea-iframe-');
@@ -49,6 +61,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);
u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe));
iframe.src = u.href;
}

View File

@@ -0,0 +1,20 @@
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
import type {Toast} from './toast.ts';
import {registerGlobalInitFunc} from './observer.ts';
type LevelMap = Record<string, (message: string) => Toast | null>;
export function initDevtest() {
registerGlobalInitFunc('initDevtestPage', () => {
const els = document.querySelectorAll('.toast-test-button');
if (!els.length) return;
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
for (const el of els) {
el.addEventListener('click', () => {
const level = el.getAttribute('data-toast-level')!;
const message = el.getAttribute('data-toast-message')!;
levelMap[level](message);
});
}
});
}

View File

@@ -1,18 +0,0 @@
import '../../css/standalone/devtest.css';
import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts';
type LevelMap = Record<string, (message: string) => Toast | null>;
function initDevtestToast() {
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
for (const el of document.querySelectorAll('.toast-test-button')) {
el.addEventListener('click', () => {
const level = el.getAttribute('data-toast-level')!;
const message = el.getAttribute('data-toast-message')!;
levelMap[level](message);
});
}
}
// NOTICE: keep in mind that this file is not in "index.js", they do not share the same module system.
initDevtestToast();

View File

@@ -1,48 +0,0 @@
import '../../css/standalone/swagger.css';
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
import 'swagger-ui-dist/swagger-ui.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);
window.addEventListener('load', async () => {
const elSwaggerUi = document.querySelector('#swagger-ui')!;
const url = elSwaggerUi.getAttribute('data-source')!;
let spec: any;
if (url) {
const res = await fetch(url); // eslint-disable-line no-restricted-globals
spec = await res.json();
} else {
const elSpecContent = elSwaggerUi.querySelector<HTMLTextAreaElement>('.swagger-spec-content')!;
const filename = elSpecContent.getAttribute('data-spec-filename');
const isJson = filename?.toLowerCase().endsWith('.json');
spec = isJson ? JSON.parse(elSpecContent.value) : loadYaml(elSpecContent.value);
}
// Make the page's protocol be at the top of the schemes list
const proto = window.location.protocol.slice(0, -1);
if (spec?.schemes) {
spec.schemes.sort((a: string, b: string) => {
if (a === proto) return -1;
if (b === proto) return 1;
return 0;
});
}
SwaggerUI({
spec,
dom_id: '#swagger-ui',
deepLinking: true,
docExpansion: 'none',
defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete
presets: [
SwaggerUI.presets.apis,
],
plugins: [
SwaggerUI.plugins.DownloadUrl,
],
});
});

70
web_src/js/swagger.ts Normal file
View File

@@ -0,0 +1,70 @@
// AVOID importing other unneeded main site JS modules to prevent unnecessary code and dependencies and chunks.
//
// Swagger JS is standalone because it is also used by external render like "File View -> OpenAPI render",
// and it doesn't need any code from main site's modules (at the moment).
//
// In the future, if there are common utilities needed by both main site and standalone Swagger,
// we can merge this standalone module into "index.ts", do pay attention to the following problems:
// * HINT: SWAGGER-OPENAPI-VIEWER: there are different places rendering the swagger UI.
// * Handle CSS styles carefully for different cases (standalone page, embedded in iframe)
// * Take care of the JS code introduced by "index.ts" and "iife.ts", there might be global variable dependency and event listeners.
import '../css/swagger.css';
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
import 'swagger-ui-dist/swagger-ui.css';
import {load as loadYaml} from 'js-yaml';
function syncDarkModeClass(): void {
// if the viewer is embedded in an iframe (external render), use the parent's theme (passed via query param)
// otherwise, if it is for Gitea's API, it is a standalone page, use the site's theme (detected from theme CSS variable)
const url = new URL(window.location.href);
const giteaIsDarkTheme = url.searchParams.get('gitea-is-dark-theme') ??
window.getComputedStyle(document.documentElement).getPropertyValue('--is-dark-theme').trim();
const isDark = giteaIsDarkTheme ? giteaIsDarkTheme === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark-mode', isDark);
}
async function initSwaggerUI() {
// swagger-ui has built-in dark mode triggered by html.dark-mode class
syncDarkModeClass();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass);
const elSwaggerUi = document.querySelector('#swagger-ui')!;
const url = elSwaggerUi.getAttribute('data-source')!;
let spec: any;
if (url) {
const res = await fetch(url); // eslint-disable-line no-restricted-globals
spec = await res.json();
} else {
const elSpecContent = elSwaggerUi.querySelector<HTMLTextAreaElement>('.swagger-spec-content')!;
const filename = elSpecContent.getAttribute('data-spec-filename');
const isJson = filename?.toLowerCase().endsWith('.json');
spec = isJson ? JSON.parse(elSpecContent.value) : loadYaml(elSpecContent.value);
}
// Make the page's protocol be at the top of the schemes list
const proto = window.location.protocol.slice(0, -1);
if (spec?.schemes) {
spec.schemes.sort((a: string, b: string) => {
if (a === proto) return -1;
if (b === proto) return 1;
return 0;
});
}
SwaggerUI({
spec,
dom_id: '#swagger-ui',
deepLinking: true,
docExpansion: 'none',
defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete
presets: [
SwaggerUI.presets.apis,
],
plugins: [
SwaggerUI.plugins.DownloadUrl,
],
});
}
initSwaggerUI();