Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
73659e4
Render 3d models into iframe
silverwind Apr 15, 2026
76b22d9
fine tune
wxiaoguang Apr 15, 2026
28ff9e5
fix comment
wxiaoguang Apr 15, 2026
2780dc4
Move 3D iframe rendering to frontend
silverwind Apr 15, 2026
2bd893e
Remove dash from viewer-3d naming
silverwind Apr 15, 2026
8eaf95c
Restore explicit edgeSettings
silverwind Apr 15, 2026
5ce09dd
Integrate 3D plugin with external-render framework
silverwind Apr 15, 2026
a234c1b
Revert to backend-rendered 3D viewer
silverwind Apr 15, 2026
dbf71b9
Merge branch 'main' into 3diframe
wxiaoguang Apr 16, 2026
a149a1c
refactor
wxiaoguang Apr 16, 2026
40f7a9b
lazy load
wxiaoguang Apr 16, 2026
72f2736
add comment
wxiaoguang Apr 16, 2026
5f18aba
Fix iframe content corruption and ref double-escape
silverwind Apr 16, 2026
cd9cc52
Surface plugin errors via console.error
silverwind Apr 16, 2026
2a99783
Add e2e regression test for asciicast on non-ASCII branch
silverwind Apr 16, 2026
49b0cb9
Merge branch 'main' into 3diframe
silverwind Apr 16, 2026
1c882f4
Remove obsolete test, covered by e2e
silverwind Apr 16, 2026
f259edd
Apply suggestion from @silverwind
silverwind Apr 16, 2026
b85c711
add comment and TODO
wxiaoguang Apr 17, 2026
7487d79
revert wrongly deleted comment
wxiaoguang Apr 17, 2026
78e11b3
add more comments
wxiaoguang Apr 17, 2026
23eb19b
Fix 3D viewer collapsed-iframe regression, expand e2e
silverwind Apr 17, 2026
42070d9
remove iframe window margin/padding
wxiaoguang Apr 17, 2026
c1e33ed
remove markup class for iframe render
wxiaoguang Apr 17, 2026
e6d8a45
Assert iframe/container height for asciicast and openapi e2e
silverwind Apr 17, 2026
9002c44
Fix TestRenderIFrame after data-global-init addition
silverwind Apr 17, 2026
80b3db8
fix sandboxed console error
wxiaoguang Apr 17, 2026
4499460
fix border radius
wxiaoguang Apr 17, 2026
7900812
Expand openapi e2e to exercise swagger-ui $ref resolver
silverwind Apr 17, 2026
1e44f97
fix lint
wxiaoguang Apr 17, 2026
00f73ec
Fix swagger pushState in srcdoc iframe, lint and quality cleanup
silverwind Apr 17, 2026
942292c
Revert arrow simplification in lazy plugin loaders
silverwind Apr 17, 2026
28bea73
Merge branch 'main' into 3diframe
silverwind Apr 17, 2026
95d1797
add comment
wxiaoguang Apr 17, 2026
782cb42
fix render query params
wxiaoguang Apr 17, 2026
adfa063
Merge branch 'main' into 3diframe
silverwind Apr 17, 2026
c1c7354
Add e2e assertions for renderer padding and 3D bgcolor
silverwind Apr 17, 2026
3372b59
Merge branch 'main' into 3diframe
GiteaBot Apr 17, 2026
81700d9
Avoid non-null assertions on boundingBox in assertFlushWithParent
silverwind Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
8 changes: 6 additions & 2 deletions models/renderhelper/repo_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
28 changes: 27 additions & 1 deletion modules/markup/external/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
Expand Down
95 changes: 95 additions & 0 deletions modules/markup/external/frontend.go
Original file line number Diff line number Diff line change
@@ -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,
`<!DOCTYPE html>
<html>
<head>
<!-- external-render-helper will be injected here by the markup render -->
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="frontend-render-viewer" data-frontend-renders="%s" data-file-tree-path="%s"></div>
<textarea id="frontend-render-data" data-content-encoding="%s" hidden>%s</textarea>
<script nonce type="module" src="%s"></script>
</body>
</html>`,
p.name, ctx.RenderOptions.RelativePath,
contentEncoding, contentString,
public.AssetURI("js/external-render-frontend.js"))
return err
}
84 changes: 0 additions & 84 deletions modules/markup/external/openapi.go

This file was deleted.

29 changes: 21 additions & 8 deletions modules/markup/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package markup
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io"
Expand Down Expand Up @@ -43,7 +44,8 @@ type WebThemeInterface interface {
}

type StandalonePageOptions struct {
CurrentWebTheme WebThemeInterface
CurrentWebTheme WebThemeInterface
RenderQueryString string
}

type RenderOptions struct {
Expand Down Expand Up @@ -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, `<iframe data-src="%s" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
return err
}

Expand All @@ -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
}
Expand All @@ -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 <iframe> should be outputted to embed the external rendered page
Expand All @@ -248,7 +256,12 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
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"
// 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 nonce crossorigin src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraLinkHref)
extraHeadHTML = htmlutil.HTMLFormat(
`<script nonce crossorigin src="%s" id="gitea-external-render-helper" data-render-query-string="%s"></script>`+
`<link rel="stylesheet" href="%s">`,
extraScriptSrc, ctx.RenderOptions.StandalonePageOptions.RenderQueryString,
extraLinkHref,
)
}

ctx.usedByRender = true
Expand Down
4 changes: 2 additions & 2 deletions modules/markup/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func TestRenderIFrame(t *testing.T) {

// 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)
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" 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)
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe" sandbox="allow"></iframe>`, ret)
}
2 changes: 2 additions & 0 deletions modules/public/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ func parseManifest(data []byte) (map[string]string, map[string]string) {
paths[key] = entry.File
names[entry.File] = entry.Name
// Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css"
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: the logic is wrong, Vite manifest doesn't work this way
// It just happens to be correct for the current modules dependencies
for _, css := range entry.CSS {
cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css)
paths[cssKey] = css
Expand Down
3 changes: 2 additions & 1 deletion routers/web/repo/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ func RenderFile(ctx *context.Context) {
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
}).WithRelativePath(ctx.Repo.TreePath).WithStandalonePage(markup.StandalonePageOptions{
CurrentWebTheme: ctx.TemplateContext.CurrentWebTheme(),
CurrentWebTheme: ctx.TemplateContext.CurrentWebTheme(),
RenderQueryString: ctx.Req.URL.RawQuery,
})
renderer, rendererInput, err := rctx.DetectMarkupRendererByReader(blobReader)
if err != nil {
Expand Down
10 changes: 6 additions & 4 deletions routers/web/repo/view_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/git/attribute"
"code.gitea.io/gitea/modules/highlight"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
Expand Down Expand Up @@ -76,14 +77,17 @@ func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Re
return false
}

ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType

var err error
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRenderToHTML(ctx, rctx, renderer, utf8Reader)
if err != nil {
ctx.ServerError("Render", err)
return true
}

opts, ok := markup.GetExternalRendererOptions(renderer)
usingIframe := ok && opts.DisplayInIframe
ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType
ctx.Data["RenderAsMarkup"] = util.Iif(usingIframe, "markup-iframe", "markup-inplace")
return true
}

Expand Down Expand Up @@ -235,8 +239,6 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
case fInfo.blobOrLfsSize >= setting.UI.MaxDisplayFileSize:
ctx.Data["IsFileTooLarge"] = true
case handleFileViewRenderMarkup(ctx, buf, contentReader):
// it also sets ctx.Data["FileContent"] and more
ctx.Data["IsMarkup"] = true
case handleFileViewRenderSource(ctx, attrs, fInfo, contentReader):
// it also sets ctx.Data["FileContent"] and more
ctx.Data["IsDisplayingSource"] = true
Expand Down
Loading