diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go index de06e7dac70..91230e54d02 100644 --- a/modules/markup/external/openapi.go +++ b/modules/markup/external/openapi.go @@ -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( ` diff --git a/modules/markup/render.go b/modules/markup/render.go index c0d44c72fcc..caed3428e0b 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,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(``, src, sandboxAttrValue) - _, err := io.WriteString(output, string(iframe)) + _, err := htmlutil.HTMLPrintf(output, ``, 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 `, ret) + + ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"}) + assert.Equal(t, ``, ret) +} diff --git a/modules/public/manifest.go b/modules/public/manifest.go index 77e89599672..a07cabd6cf1 100644 --- a/modules/public/manifest.go +++ b/modules/public/manifest.go @@ -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. diff --git a/modules/public/manifest_test.go b/modules/public/manifest_test.go index 20a2232cf38..acfeaa6dbeb 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/public.go b/modules/public/public.go index 004aad5f3b1..bb4721a48d3 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -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() diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 25bd28a8265..7cfe692390b 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -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 { diff --git a/routers/web/misc/swagger.go b/routers/web/misc/swagger.go index 1ca347551c1..4abd4f042d6 100644 --- a/routers/web/misc/swagger.go +++ b/routers/web/misc/swagger.go @@ -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") } diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index b1299c7047e..160f6315855 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -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) diff --git a/routers/web/web.go b/routers/web/web.go index e3dcf27cc4a..86397bb9394 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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")) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index f8322381ca1..e82008c6871 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -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" diff --git a/stylelint.config.js b/stylelint.config.js index 3e6be3c2487..0aee1a5dac2 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, }, diff --git a/tailwind.config.ts b/tailwind.config.ts index 7aaea687eb3..415837f8922 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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(); @@ -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', diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl index 15fa7ad730c..4a4fb9d96f8 100644 --- a/templates/base/head_style.tmpl +++ b/templates/base/head_style.tmpl @@ -1,2 +1,2 @@ - + diff --git a/templates/devtest/devtest-footer.tmpl b/templates/devtest/devtest-footer.tmpl index 868136e1948..091a1035a9f 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/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl index a7aebcb7dc8..628e4388a0c 100644 --- a/templates/devtest/devtest-header.tmpl +++ b/templates/devtest/devtest-header.tmpl @@ -1,8 +1,4 @@ {{template "base/head" ctx.RootData}} - +
{{template "base/alert" .}} diff --git a/templates/swagger/ui.tmpl b/templates/swagger/openapi-viewer.tmpl similarity index 51% rename from templates/swagger/ui.tmpl rename to templates/swagger/openapi-viewer.tmpl index d53a6111764..0360f169531 100644 --- a/templates/swagger/ui.tmpl +++ b/templates/swagger/openapi-viewer.tmpl @@ -2,13 +2,15 @@ Gitea API - + {{/* HINT: SWAGGER-OPENAPI-VIEWER: another place is "modules/markup/external/openapi.go" */}} + + {{/* 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/swagger.js" "module"}} diff --git a/tests/e2e/external-render.test.ts b/tests/e2e/external-render.test.ts new file mode 100644 index 00000000000..50adb6429e0 --- /dev/null +++ b/tests/e2e/external-render.test.ts @@ -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', '

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`)); + 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); + } +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 442a40d8e56..7a4a91c2699 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 3d9d7b39696..8baa266962d 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")) }) }) diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index ed3d8c402e4..b8bf6718392 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 375c127ce09..cc446f6558a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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(); 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(), 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/markup/content.css b/web_src/css/markup/content.css index d0655af0027..e7a967a7c64 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -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 { 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 2997587d820..00000000000 --- a/web_src/css/standalone/external-render-iframe.css +++ /dev/null @@ -1 +0,0 @@ -/* dummy */ diff --git a/web_src/css/standalone/swagger.css b/web_src/css/standalone/swagger.css deleted file mode 100644 index e65af5ded63..00000000000 --- a/web_src/css/standalone/swagger.css +++ /dev/null @@ -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; -} diff --git a/web_src/css/swagger.css b/web_src/css/swagger.css new file mode 100644 index 00000000000..c20eda7948d --- /dev/null +++ b/web_src/css/swagger.css @@ -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; +} diff --git a/web_src/js/features/eventsource.sharedworker.ts b/web_src/js/eventsource.sharedworker.ts similarity index 100% rename from web_src/js/features/eventsource.sharedworker.ts rename to web_src/js/eventsource.sharedworker.ts diff --git a/web_src/js/standalone/external-render-iframe.ts b/web_src/js/external-render-helper.ts similarity index 61% rename from web_src/js/standalone/external-render-iframe.ts rename to web_src/js/external-render-helper.ts index 3b489f8ee38..3acac8db141 100644 --- a/web_src/js/standalone/external-render-iframe.ts +++ b/web_src/js/external-render-helper.ts @@ -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 '
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); + }); + } + }); +} diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/standalone/devtest.ts deleted file mode 100644 index 20ab163d1a2..00000000000 --- a/web_src/js/standalone/devtest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import '../../css/standalone/devtest.css'; -import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts'; - -type LevelMap = Record 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(); diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/standalone/swagger.ts deleted file mode 100644 index fc44c8501ac..00000000000 --- a/web_src/js/standalone/swagger.ts +++ /dev/null @@ -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('.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, - ], - }); -}); diff --git a/web_src/js/swagger.ts b/web_src/js/swagger.ts new file mode 100644 index 00000000000..b2f6a61030a --- /dev/null +++ b/web_src/js/swagger.ts @@ -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('.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();