diff --git a/eslint.config.ts b/eslint.config.ts index 0a327e333df..29016ed808b 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -924,6 +924,7 @@ export default defineConfig([ { ...playwright.configs['flat/recommended'], files: ['tests/e2e/**/*.test.ts'], + languageOptions: {globals: {...globals.nodeBuiltin, ...globals.browser}}, rules: { ...playwright.configs['flat/recommended'].rules, 'playwright/expect-expect': [0], diff --git a/models/renderhelper/repo_file.go b/models/renderhelper/repo_file.go index f1df8e89e0e..5d0bfd6c80f 100644 --- a/models/renderhelper/repo_file.go +++ b/models/renderhelper/repo_file.go @@ -50,8 +50,8 @@ type RepoFileOptions struct { DeprecatedRepoName string // it is only a patch for the non-standard "markup" api DeprecatedOwnerName string // it is only a patch for the non-standard "markup" api - CurrentRefPath string // eg: "branch/main" - CurrentTreePath string // eg: "path/to/file" in the repo + CurrentRefPath string // eg: "branch/main", it is a sub URL path escaped by callers, TODO: rename to CurrentRefSubURL + CurrentTreePath string // eg: "path/to/file" in the repo, it is the tree path without URL path escaping } func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, opts ...RepoFileOptions) *markup.RenderContext { @@ -70,6 +70,10 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, "repo": helper.opts.DeprecatedRepoName, }) } + // External render's iframe needs this to generate correct links + // TODO: maybe need to make it access "CurrentRefPath" directly (but impossible at the moment due to cycle-import) + // CurrentRefPath is already path-escaped by callers + rctx.RenderOptions.Metas["RefTypeNameSubURL"] = helper.opts.CurrentRefPath rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true) return rctx } diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 4d447e301ab..4b3c96fd33d 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -21,7 +21,33 @@ import ( // RegisterRenderers registers all supported third part renderers according settings func RegisterRenderers() { - markup.RegisterRenderer(&openAPIRenderer{}) + markup.RegisterRenderer(&frontendRenderer{ + name: "openapi-swagger", + patterns: []string{ + "openapi.yaml", + "openapi.yml", + "openapi.json", + "swagger.yaml", + "swagger.yml", + "swagger.json", + }, + }) + + markup.RegisterRenderer(&frontendRenderer{ + name: "viewer-3d", + patterns: []string{ + // It needs more logic to make it overall right (render a text 3D model automatically): + // we need to distinguish the ambiguous filename extensions. + // For example: "*.amf, *.obj, *.off, *.step" might be or not be a 3D model file. + // So when it is a text file, we can't assume that "we only render it by 3D plugin", + // otherwise the end users would be impossible to view its real content when the file is not a 3D model. + "*.3dm", "*.3ds", "*.3mf", "*.amf", "*.bim", "*.brep", + "*.dae", "*.fbx", "*.fcstd", "*.glb", "*.gltf", + "*.ifc", "*.igs", "*.iges", "*.stp", "*.step", + "*.stl", "*.obj", "*.off", "*.ply", "*.wrl", + }, + }) + for _, renderer := range setting.ExternalMarkupRenderers { markup.RegisterRenderer(&Renderer{renderer}) } diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go new file mode 100644 index 00000000000..7327503d28a --- /dev/null +++ b/modules/markup/external/frontend.go @@ -0,0 +1,95 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package external + +import ( + "encoding/base64" + "io" + "unicode/utf8" + + "code.gitea.io/gitea/modules/htmlutil" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/public" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +type frontendRenderer struct { + name string + patterns []string +} + +var ( + _ markup.PostProcessRenderer = (*frontendRenderer)(nil) + _ markup.ExternalRenderer = (*frontendRenderer)(nil) +) + +func (p *frontendRenderer) Name() string { + return p.name +} + +func (p *frontendRenderer) NeedPostProcess() bool { + return false +} + +func (p *frontendRenderer) FileNamePatterns() []string { + // TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model + // There are some approaches to make it more accurate, but they are all complicated: + // A. Make backend know everything (detect a file is a 3D model or not) + // B. Let frontend renders to try render one by one + // + // If there would be more frontend renders in the future, we need to implement the "frontend" approach: + // 1. Make backend or parent window collect the supported extensions of frontend renders (done: backend external render framework) + // 2. If the current file matches any extension, start the general iframe embedded render (done: this renderer) + // 3. The iframe window calls the frontend renders one by one (done: frontend external render) + // 4. Report the render result to parent by postMessage (TODO: when needed) + return p.patterns +} + +func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule { + return nil +} + +func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { + ret.SanitizerDisabled = true + ret.DisplayInIframe = true + ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" + return ret +} + +func (p *frontendRenderer) 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 + } + + contentEncoding, contentString := "text", util.UnsafeBytesToString(content) + if !utf8.Valid(content) { + contentEncoding = "base64" + contentString = base64.StdEncoding.EncodeToString(content) + } + + _, err = htmlutil.HTMLPrintf(output, + ` + +
+ + + + + + + + +`, + p.name, ctx.RenderOptions.RelativePath, + contentEncoding, contentString, + public.AssetURI("js/external-render-frontend.js")) + return err +} diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go deleted file mode 100644 index b76b88c2969..00000000000 --- a/modules/markup/external/openapi.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package external - -import ( - "fmt" - "html" - "io" - - "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/public" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" -) - -type openAPIRenderer struct{} - -var ( - _ markup.PostProcessRenderer = (*openAPIRenderer)(nil) - _ markup.ExternalRenderer = (*openAPIRenderer)(nil) -) - -func (p *openAPIRenderer) Name() string { - return "openapi" -} - -func (p *openAPIRenderer) NeedPostProcess() bool { - return false -} - -func (p *openAPIRenderer) FileNamePatterns() []string { - return []string{ - "openapi.yaml", - "openapi.yml", - "openapi.json", - "swagger.yaml", - "swagger.yml", - "swagger.json", - } -} - -func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule { - return nil -} - -func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { - ret.SanitizerDisabled = true - ret.DisplayInIframe = true - 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 - } - - // HINT: SWAGGER-OPENAPI-VIEWER: another place "templates/swagger/openapi-viewer.tmpl" - _, err = io.WriteString(output, fmt.Sprintf( - ` - - - - - - - - - -`, - public.AssetURI("css/swagger.css"), - html.EscapeString(ctx.RenderOptions.RelativePath), - html.EscapeString(util.UnsafeBytesToString(content)), - public.AssetURI("js/swagger.js"), - )) - return err -} diff --git a/modules/markup/render.go b/modules/markup/render.go index cf5b73e7a59..6e8838d49fe 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -6,6 +6,7 @@ package markup import ( "bytes" "context" + "errors" "fmt" "html/template" "io" @@ -43,7 +44,8 @@ type WebThemeInterface interface { } type StandalonePageOptions struct { - CurrentWebTheme WebThemeInterface + CurrentWebTheme WebThemeInterface + RenderQueryString string } type RenderOptions struct { @@ -206,17 +208,23 @@ func RenderString(ctx *RenderContext, content string) (string, error) { } func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error { + ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"] + refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"] + if ownerName == "" || repoName == "" || refSubURL == "" { + setting.PanicInDevOrTesting("RenderIFrame requires user, repo and RefTypeNameSubURL metas") + return errors.New("RenderIFrame requires user, repo and RefTypeNameSubURL metas") + } 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"]), + url.PathEscape(ownerName), + url.PathEscape(repoName), + ctx.RenderOptions.Metas["RefTypeNameSubURL"], util.PathEscapeSegments(ctx.RenderOptions.RelativePath), ) var extraAttrs template.HTML if opts.ContentSandbox != "" { extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox) } - _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) + _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) return err } @@ -228,7 +236,7 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) { } } -func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) { +func GetExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) { if externalRender, ok := renderer.(ExternalRenderer); ok { return externalRender.GetExternalRendererOptions(), true } @@ -237,7 +245,7 @@ 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 extOpts, ok := GetExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe { if ctx.RenderOptions.StandalonePageOptions == nil { // for an external "DisplayInIFrame" render, it could only output its content in a standalone page // otherwise, a