mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-06 21:57:51 +09:00
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:
10
modules/markup/external/openapi.go
vendored
10
modules/markup/external/openapi.go
vendored
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
31
modules/markup/render_test.go
Normal file
31
modules/markup/render_test.go
Normal 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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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>
|
||||
45
tests/e2e/external-render.test.ts
Normal file
45
tests/e2e/external-render.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"><script></script></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"><script></script></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"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/* dummy */
|
||||
@@ -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
41
web_src/css/swagger.css
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
20
web_src/js/modules/devtest.ts
Normal file
20
web_src/js/modules/devtest.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
70
web_src/js/swagger.ts
Normal 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();
|
||||
Reference in New Issue
Block a user