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 `, ret) + assert.Equal(t, ``, ret) ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"}) - assert.Equal(t, ``, ret) + assert.Equal(t, ``, ret) } diff --git a/modules/public/manifest.go b/modules/public/manifest.go index a07cabd6cf1..f807244c893 100644 --- a/modules/public/manifest.go +++ b/modules/public/manifest.go @@ -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 diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index 160f6315855..ace871a9f18 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -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 { diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 65fcb8adba2..8d7721103a3 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -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" @@ -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 } @@ -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 diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index eba3ffc36fd..25e1f87806c 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -195,16 +195,16 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil }).WithRelativePath(readmeFullPath) renderer := rctx.DetectMarkupRenderer(buf) if renderer != nil { - ctx.Data["IsMarkup"] = true + ctx.Data["RenderAsMarkup"] = "markup-inplace" ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRenderToHTML(ctx, rctx, renderer, rd) if err != nil { log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) - delete(ctx.Data, "IsMarkup") + delete(ctx.Data, "RenderAsMarkup") } } - if ctx.Data["IsMarkup"] != true { + if ctx.Data["RenderAsMarkup"] == nil { ctx.Data["IsPlainText"] = true content, err := io.ReadAll(rd) if err != nil { diff --git a/templates/repo/view_content.tmpl b/templates/repo/view_content.tmpl index 7e5d588c204..3413c09a6af 100644 --- a/templates/repo/view_content.tmpl +++ b/templates/repo/view_content.tmpl @@ -130,7 +130,7 @@ {{template "repo/code/upstream_diverging_info" .}} {{end}} {{template "repo/view_list" .}} - {{if and .ReadmeExist (or .IsMarkup .IsPlainText)}} + {{if and .ReadmeExist (or .RenderAsMarkup .IsPlainText)}} {{template "repo/view_file" .}} {{end}} {{end}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 72b5a83b65b..59243bf6840 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -40,7 +40,9 @@ {{if .IsRepresentableAsText}} {{svg "octicon-code" 15}} {{end}} + {{if .HasSourceRenderedToggle}} {{svg "octicon-file" 15}} + {{end}} {{if not .ReadmeInList}}
@@ -90,15 +92,15 @@
- {{if not .IsMarkup}} + {{if not .RenderAsMarkup}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} -
+
{{if .IsFileTooLarge}} {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} {{else if not .FileSize}} {{template "shared/fileisempty"}} - {{else if .IsMarkup}} + {{else if .RenderAsMarkup}} {{.FileContent}} {{else if .IsPlainText}}
{{if .FileContent}}{{.FileContent}}{{end}}
diff --git a/templates/swagger/openapi-viewer.tmpl b/templates/swagger/openapi-viewer.tmpl index f2f01fc0cd3..792364157d7 100644 --- a/templates/swagger/openapi-viewer.tmpl +++ b/templates/swagger/openapi-viewer.tmpl @@ -3,8 +3,8 @@ {{ctx.HeadMetaContentSecurityPolicy}} Gitea API - {{/* HINT: SWAGGER-OPENAPI-VIEWER: another place is "modules/markup/external/openapi.go" */}} + {{/* HINT: SWAGGER-CSS-IMPORT: import swagger styles ahead to avoid UI flicker (e.g.: the swagger-back-link element) */}} diff --git a/tests/e2e/external-render.test.ts b/tests/e2e/external-render.test.ts index 50adb6429e0..b989c354ff5 100644 --- a/tests/e2e/external-render.test.ts +++ b/tests/e2e/external-render.test.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect, test} from '@playwright/test'; -import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertNoJsError, randomString} from './utils.ts'; +import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts'; test('external file', async ({page, request}) => { const repoName = `e2e-external-render-${randomString(8)}`; @@ -17,6 +17,7 @@ test('external file', async ({page, request}) => { 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 assertFlushWithParent(iframe, page.locator('.file-view')); await assertNoJsError(page); } finally { await apiDeleteRepo(request, owner, repoName); @@ -31,13 +32,28 @@ test('openapi file', async ({page, request}) => { 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 title = 'Test & "quoted"'; + const spec = JSON.stringify({ + openapi: '3.0.0', + info: {title, version: '1.0'}, + paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}}, + components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}}, + }); + await apiCreateFile(request, owner, repoName, 'openapi.json', spec); + await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`); 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(); + const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer'); + await expect(viewer.locator('.swagger-ui')).toBeVisible(); + await expect(viewer.locator('.info .title')).toContainText(title); + // expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location + // (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference" + await viewer.locator('.opblock-tag').first().click(); + await viewer.locator('.opblock').first().click(); + await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0); + // poll: postMessage resize may not have settled yet when the visibility checks pass + await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); await assertNoJsError(page); } finally { await apiDeleteRepo(request, owner, repoName); diff --git a/tests/e2e/file-view-render.test.ts b/tests/e2e/file-view-render.test.ts new file mode 100644 index 00000000000..a3afe85b267 --- /dev/null +++ b/tests/e2e/file-view-render.test.ts @@ -0,0 +1,69 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import {apiCreateBranch, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts'; + +test('3d model file', async ({page, request}) => { + const repoName = `e2e-3d-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await apiCreateRepo(request, {name: repoName}); + try { + const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n'; + await apiCreateFile(request, owner, repoName, 'test.stl', stl); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`); + const iframe = page.locator('iframe.external-render-iframe'); + await expect(iframe).toBeVisible(); + const frame = page.frameLocator('iframe.external-render-iframe'); + const viewer = frame.locator('#frontend-render-viewer'); + await expect(viewer.locator('canvas')).toBeVisible(); + expect((await viewer.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(iframe, page.locator('.file-view')); + // bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent + const [parentBg, iframeBg] = await Promise.all([ + page.evaluate(() => getComputedStyle(document.body).backgroundColor), + frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor), + ]); + expect(iframeBg).toBe(parentBg); + await assertNoJsError(page); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); + +test('pdf file', async ({page, request}) => { + // headless playwright cannot render PDFs (PDFObject.embed returns false), so this is a limited test + const repoName = `e2e-pdf-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await apiCreateRepo(request, {name: repoName}); + try { + await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n'); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`); + const container = page.locator('.file-view-render-container'); + await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer'); + expect((await container.boundingBox())!.height).toBeGreaterThan(300); + await assertFlushWithParent(container, page.locator('.file-view')); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); + +test('asciicast file', async ({page, request}) => { + // regression for repo_file.go's RefTypeNameSubURL double-escape: readme.cast on a non-ASCII branch + // is rendered via view_readme.go (no metas override), exposing the bug as a broken player URL + const repoName = `e2e-asciicast-render-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + const branch = '日本語-branch'; + const branchEnc = encodeURIComponent(branch); + await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]); + try { + const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n'; + await apiCreateFile(request, owner, repoName, 'readme.cast', cast); + await apiCreateBranch(request, owner, repoName, branch); + await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`); + const container = page.locator('.asciinema-player-container'); + await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`); + await expect(container.locator('.ap-wrapper')).toBeVisible(); + expect((await container.boundingBox())!.height).toBeGreaterThan(300); + } finally { + await apiDeleteRepo(request, owner, repoName); + } +}); diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 7a4a91c2699..08de0241268 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -1,6 +1,6 @@ import {env} from 'node:process'; import {expect} from '@playwright/test'; -import type {APIRequestContext, Page} from '@playwright/test'; +import type {APIRequestContext, Locator, Page} from '@playwright/test'; /** Generate a random alphanumeric string. */ export function randomString(length: number): string { @@ -67,6 +67,13 @@ export async function apiCreateFile(requestContext: APIRequestContext, owner: st }), 'apiCreateFile'); } +export async function apiCreateBranch(requestContext: APIRequestContext, owner: string, repo: string, newBranch: string) { + await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/branches`, { + headers: apiHeaders(), + data: {new_branch_name: newBranch}, + }), 'apiCreateBranch'); +} + export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) { await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, { headers: apiHeaders(), @@ -115,6 +122,15 @@ export async function assertNoJsError(page: Page) { await expect(page.locator('.js-global-error')).toHaveCount(0); } +/* asserts the child has no horizontal inset from its parent — catches padding/border anywhere + * in between regardless of which element declares it */ +export async function assertFlushWithParent(child: Locator, parent: Locator) { + const [childBox, parentBox] = await Promise.all([child.boundingBox(), parent.boundingBox()]); + if (!childBox || !parentBox) throw new Error('boundingBox returned null'); + expect(childBox.x).toBe(parentBox.x); + expect(childBox.width).toBe(parentBox.width); +} + export async function logout(page: Page) { await page.context().clearCookies(); // workaround issues related to fomantic dropdown await page.goto('/'); diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index 681b981a4e1..97ef7e0b22a 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -108,7 +108,12 @@ 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(), + ) }) }) @@ -129,9 +134,14 @@ func TestExternalMarkupRenderer(t *testing.T) { }) t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { - req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") + req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer?a=1%2f2") 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/vite.config.ts b/vite.config.ts index 7249cb902eb..731726c3183 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -152,9 +152,15 @@ function iifePlugin(sourceFileName: string): Plugin { if (!entry) throw new Error('IIFE build produced no output'); const manifestPath = join(outDir, '.vite', 'manifest.json'); - 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)); + try { + 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)); + } catch { + // FIXME: if it throws error here, the real Vite compilation error will be hidden, and makes the debug very difficult + // Need to find a correct way to handle errors. + console.error(`Failed to update manifest for ${sourceFileName}`); + } }, }; } @@ -165,6 +171,7 @@ function reducedSourcemapPlugin(): Plugin { 'js/index.', 'js/iife.', 'js/swagger.', + 'js/external-render-frontend.', 'js/external-render-helper.', 'js/eventsource.sharedworker.', ]; @@ -251,8 +258,10 @@ export default defineConfig(commonViteOpts({ manifest: true, rolldownOptions: { input: { + // FIXME: INCORRECT-VITE-MANIFEST-PARSER: the "css importing" logic in backend is wrong index: join(import.meta.dirname, 'web_src/js/index.ts'), swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'), + 'external-render-frontend': join(import.meta.dirname, 'web_src/js/external-render-frontend.ts'), 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/eventsource.sharedworker.ts'), devtest: join(import.meta.dirname, 'web_src/css/devtest.css'), ...themes, diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index efa6947ef14..d90e3e01ec5 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -458,9 +458,11 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { } .external-render-iframe { + display: block; /* removes the inline baseline gap below the iframe */ width: 100%; height: max(300px, 80vh); border: none; + border-radius: 0 0 var(--border-radius) var(--border-radius); } .markup-content-iframe { diff --git a/web_src/js/external-render-frontend.ts b/web_src/js/external-render-frontend.ts new file mode 100644 index 00000000000..9d969bcf900 --- /dev/null +++ b/web_src/js/external-render-frontend.ts @@ -0,0 +1,67 @@ +import type {FrontendRenderFunc, FrontendRenderOptions} from './render/plugin.ts'; + +type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>; + +// It must use a wrapper function to avoid the "import" statement being treated +// as static import and cause the all plugins being loaded together, +// We only need to load the plugins we need. +const frontendPlugins: Record = { + 'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'), + 'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'), +}; + +class Options implements FrontendRenderOptions { + container: HTMLElement; + treePath: string; + rawEncoding: string; + rawString: string; + cachedBytes: Uint8Array | null = null; + cachedString: string | null = null; + constructor(container: HTMLElement, treePath: string, rawEncoding: string, rawString: string) { + this.container = container; + this.treePath = treePath; + this.rawEncoding = rawEncoding; + this.rawString = rawString; + } + decodeBase64(): Uint8Array { + return Uint8Array.from(atob(this.rawString), (c) => c.charCodeAt(0)); + } + contentBytes(): Uint8Array { + if (this.cachedBytes === null) { + this.cachedBytes = this.rawEncoding === 'base64' ? this.decodeBase64() : new TextEncoder().encode(this.rawString); + } + return this.cachedBytes; + } + contentString(): string { + if (this.cachedString === null) { + this.cachedString = this.rawEncoding === 'base64' ? new TextDecoder('utf-8').decode(this.decodeBase64()) : this.rawString; + } + return this.cachedString; + } +} + +async function initFrontendExternalRender() { + const viewerContainer = document.querySelector('#frontend-render-viewer')!; + const renderNames = viewerContainer.getAttribute('data-frontend-renders')!.split(' '); + const fileTreePath = viewerContainer.getAttribute('data-file-tree-path')!; + + const fileDataElem = document.querySelector('#frontend-render-data')!; + fileDataElem.remove(); + const fileDataContent = fileDataElem.value; + const fileDataEncoding = fileDataElem.getAttribute('data-content-encoding')!; + const opts = new Options(viewerContainer, fileTreePath, fileDataEncoding, fileDataContent); + + let found = false; + for (const name of renderNames) { + if (!(name in frontendPlugins)) continue; + const plugin = await frontendPlugins[name](); + found = true; + if (await plugin.frontendRender(opts)) break; + } + + if (!found) { + viewerContainer.textContent = 'No frontend render plugin found for this file, but backend declares that there must be one, there must be a bug'; + } +} + +initFrontendExternalRender(); diff --git a/web_src/js/external-render-helper.ts b/web_src/js/external-render-helper.ts index 9162d0f550d..f92aeb9c6c9 100644 --- a/web_src/js/external-render-helper.ts +++ b/web_src/js/external-render-helper.ts @@ -26,14 +26,16 @@ function isValidCssColor(s: string | null): boolean { return reHex.test(s) || reRgb.test(s); } -const url = new URL(window.location.href); +const thisScriptElem = document.querySelector('script#gitea-external-render-helper'); +const queryString = thisScriptElem?.getAttribute('data-render-query-string') ?? window.location.search.substring(1); +const queryParams = new URLSearchParams(queryString); -const isDarkTheme = url.searchParams.get('gitea-is-dark-theme') === 'true'; +const isDarkTheme = queryParams.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'); +const backgroundColor = queryParams.get('gitea-iframe-bgcolor'); if (isValidCssColor(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'); @@ -41,12 +43,13 @@ if (isValidCssColor(backgroundColor)) { :root { --gitea-iframe-bgcolor: ${backgroundColor}; } +html, body { margin: 0; padding: 0 } body { background: ${backgroundColor}; } `; document.head.append(style); } -const iframeId = url.searchParams.get('gitea-iframe-id'); +const iframeId = queryParams.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 = {}) => { diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index ff9e8cfa263..b9a5dd5094f 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -1,29 +1,19 @@ -import type {FileRenderPlugin} from '../render/plugin.ts'; -import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; -import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; +import type {InplaceRenderPlugin} from '../render/plugin.ts'; +import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; -import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; import {html} from '../utils/html.ts'; import {basename} from '../utils.ts'; -const plugins: FileRenderPlugin[] = []; +const inplacePlugins: InplaceRenderPlugin[] = []; -function initPluginsOnce(): void { - if (plugins.length) return; - plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer()); +function initInplacePluginsOnce(): void { + if (inplacePlugins.length) return; + inplacePlugins.push(newInplacePluginPdfViewer()); } -function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { - return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; -} - -function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void { - const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons')!; - showElem(toggleButtons); - const displayingRendered = Boolean(renderContainer); - toggleElemClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist - toggleElemClass(toggleButtons.querySelector('.file-view-toggle-rendered')!, 'active', displayingRendered); - // TODO: if there is only one button, hide it? +function findInplaceRenderPlugin(filename: string, mimeType: string): InplaceRenderPlugin | null { + return inplacePlugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; } async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) { @@ -32,7 +22,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str let rendered = false, errorMsg = ''; try { - const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType); if (plugin) { container.classList.add('is-loading'); container.setAttribute('data-render-name', plugin.name); // not used yet @@ -61,16 +51,13 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str export function initRepoFileView(): void { registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { - initPluginsOnce(); + initInplacePluginsOnce(); const rawFileLink = elFileView.getAttribute('data-raw-file-link')!; const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet - // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not - const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType); if (!plugin) return; const renderContainer = elFileView.querySelector('.file-view-render-container'); - showRenderRawFileButton(elFileView, renderContainer); - // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType); }); } diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts index 63510458f9b..77ba0eaed4f 100644 --- a/web_src/js/markup/content.ts +++ b/web_src/js/markup/content.ts @@ -3,13 +3,14 @@ import {initMarkupCodeMath} from './math.ts'; import {initMarkupCodeCopy} from './codecopy.ts'; import {initMarkupRenderAsciicast} from './asciicast.ts'; import {initMarkupTasklist} from './tasklist.ts'; -import {registerGlobalSelectorFunc} from '../modules/observer.ts'; -import {initMarkupRenderIframe} from './render-iframe.ts'; +import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; +import {initExternalRenderIframe} from './render-iframe.ts'; import {initMarkupRefIssue} from './refissue.ts'; import {toggleElemClass} from '../utils/dom.ts'; // code that runs for all markup content export function initMarkupContent(): void { + registerGlobalInitFunc('initExternalRenderIframe', initExternalRenderIframe); registerGlobalSelectorFunc('.markup', (el: HTMLElement) => { if (el.matches('.truncated-markup')) { // when the rendered markup is truncated (e.g.: user's home activity feed) @@ -25,7 +26,6 @@ export function initMarkupContent(): void { initMarkupCodeMermaid(el); initMarkupCodeMath(el); initMarkupRenderAsciicast(el); - initMarkupRenderIframe(el); initMarkupRefIssue(el); }); } diff --git a/web_src/js/markup/render-iframe.ts b/web_src/js/markup/render-iframe.ts index 09493df7802..2b1b06e5c0b 100644 --- a/web_src/js/markup/render-iframe.ts +++ b/web_src/js/markup/render-iframe.ts @@ -1,5 +1,6 @@ -import {generateElemId, queryElemChildren} from '../utils/dom.ts'; +import {generateElemId} from '../utils/dom.ts'; import {isDarkTheme} from '../utils.ts'; +import {GET} from '../modules/fetch.ts'; function safeRenderIframeLink(link: any): string | null { try { @@ -41,7 +42,7 @@ function getRealBackgroundColor(el: HTMLElement) { return ''; } -async function loadRenderIframeContent(iframe: HTMLIFrameElement) { +export async function initExternalRenderIframe(iframe: HTMLIFrameElement) { const iframeSrcUrl = iframe.getAttribute('data-src')!; if (!iframe.id) iframe.id = generateElemId('gitea-iframe-'); @@ -62,9 +63,10 @@ async function loadRenderIframeContent(iframe: HTMLIFrameElement) { 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; -} -export function initMarkupRenderIframe(el: HTMLElement) { - queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent); + // It must use "srcdoc" here, because our backend always sends CSP sandbox directive for the rendered content + // (to protect from XSS risks), so we can't use "src" to load the content directly, otherwise there will be console errors like: + // Unsafe attempt to load URL http://localhost:3000/test from frame with URL http://localhost:3000/test + const resp = await GET(u.href); + iframe.srcdoc = await resp.text(); } diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts index 234be4118f4..368c73dea36 100644 --- a/web_src/js/render/plugin.ts +++ b/web_src/js/render/plugin.ts @@ -1,10 +1,21 @@ -export type FileRenderPlugin = { - // unique plugin name +// there are 2 kinds of plugins: +// * "inplace" plugins: render file content in-place, e.g. PDF viewer +// * "frontend" plugins: render file content in a separate iframe by a huge frontend library (need to protect from XSS risks) +// TODO: render plugin enhancements, not needed at the moment, leave the problems to the future when the problems actually come: +// 1. provide the prefetched file head bytes to let the plugin decide whether to render or not +// 2. multiple plugins can render the same file, so we should not assume only one plugin will render it + +export type InplaceRenderPlugin = { name: string; - - // test if plugin can handle a specified file canHandle: (filename: string, mimeType: string) => boolean; - - // render file content render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; }; + +export type FrontendRenderOptions = { + container: HTMLElement; + treePath: string; + contentString(): string; + contentBytes(): Uint8Array; +}; + +export type FrontendRenderFunc = (opts: FrontendRenderOptions) => Promise; diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts deleted file mode 100644 index f997790af69..00000000000 --- a/web_src/js/render/plugins/3d-viewer.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type {FileRenderPlugin} from '../plugin.ts'; -import {extname} from '../../utils.ts'; - -// support common 3D model file formats, use online-3d-viewer library for rendering - -/* a simple text STL file example: -solid SimpleTriangle - facet normal 0 0 1 - outer loop - vertex 0 0 0 - vertex 1 0 0 - vertex 0 1 0 - endloop - endfacet -endsolid SimpleTriangle -*/ - -export function newRenderPlugin3DViewer(): FileRenderPlugin { - // Some extensions are text-based formats: - // .3mf .amf .brep: XML - // .fbx: XML or BINARY - // .dae .gltf: JSON - // .ifc, .igs, .iges, .stp, .step are: TEXT - // .stl .ply: TEXT or BINARY - // .obj .off .wrl: TEXT - // So we need to be able to render when the file is recognized as plaintext file by backend. - // - // 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: "*.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. - const SUPPORTED_EXTENSIONS = [ - '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', - '.dae', '.fbx', '.fcstd', '.glb', '.gltf', - '.ifc', '.igs', '.iges', '.stp', '.step', - '.stl', '.obj', '.off', '.ply', '.wrl', - ]; - - return { - name: '3d-model-viewer', - - canHandle(filename: string, _mimeType: string): boolean { - const ext = extname(filename).toLowerCase(); - return SUPPORTED_EXTENSIONS.includes(ext); - }, - - async render(container: HTMLElement, fileUrl: string): Promise { - // TODO: height and/or max-height? - const OV = await import('online-3d-viewer'); - const viewer = new OV.EmbeddedViewer(container, { - backgroundColor: new OV.RGBAColor(59, 68, 76, 0), - defaultColor: new OV.RGBColor(65, 131, 196), - edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), - }); - viewer.LoadModelFromUrlList([fileUrl]); - }, - }; -} diff --git a/web_src/js/render/plugins/frontend-openapi-swagger.ts b/web_src/js/render/plugins/frontend-openapi-swagger.ts new file mode 100644 index 00000000000..cc8d3451f24 --- /dev/null +++ b/web_src/js/render/plugins/frontend-openapi-swagger.ts @@ -0,0 +1,17 @@ +import type {FrontendRenderFunc} from '../plugin.ts'; +import {initSwaggerUI} from '../swagger.ts'; + +// HINT: SWAGGER-CSS-IMPORT: this import is also necessary when swagger is used as a frontend external render +// It must be on top-level, doesn't work in a function +// Static import doesn't work (it needs to use manifest.json to manually add the CSS file) +await import('../../../css/swagger.css'); + +export const frontendRender: FrontendRenderFunc = async (opts): Promise => { + try { + await initSwaggerUI(opts.container, {specText: opts.contentString()}); + return true; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/web_src/js/render/plugins/frontend-viewer-3d.ts b/web_src/js/render/plugins/frontend-viewer-3d.ts new file mode 100644 index 00000000000..f7d1c4d0541 --- /dev/null +++ b/web_src/js/render/plugins/frontend-viewer-3d.ts @@ -0,0 +1,36 @@ +import type {FrontendRenderFunc} from '../plugin.ts'; +import {basename} from '../../utils.ts'; +import * as OV from 'online-3d-viewer'; +import {colord} from 'colord'; + +/* a simple text STL file example: +solid SimpleTriangle + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid SimpleTriangle +*/ + +export const frontendRender: FrontendRenderFunc = async (opts): Promise => { + try { + opts.container.style.height = `${window.innerHeight}px`; + const bgColor = colord(getComputedStyle(document.body).backgroundColor).toRgb(); + const primaryColor = colord(getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()).toRgb(); + const viewer = new OV.EmbeddedViewer(opts.container, { + backgroundColor: new OV.RGBAColor(bgColor.r, bgColor.g, bgColor.b, 255), + defaultColor: new OV.RGBColor(primaryColor.r, primaryColor.g, primaryColor.b), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + const blob = new Blob([opts.contentBytes()]); + const file = new File([blob], basename(opts.treePath)); + viewer.LoadModelFromFileList([file]); + return true; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/inplace-pdf-viewer.ts similarity index 69% rename from web_src/js/render/plugins/pdf-viewer.ts rename to web_src/js/render/plugins/inplace-pdf-viewer.ts index c7040e96ef1..7447f38ec4e 100644 --- a/web_src/js/render/plugins/pdf-viewer.ts +++ b/web_src/js/render/plugins/inplace-pdf-viewer.ts @@ -1,6 +1,6 @@ -import type {FileRenderPlugin} from '../plugin.ts'; +import type {InplaceRenderPlugin} from '../plugin.ts'; -export function newRenderPluginPdfViewer(): FileRenderPlugin { +export function newInplacePluginPdfViewer(): InplaceRenderPlugin { return { name: 'pdf-viewer', @@ -11,6 +11,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin { async render(container: HTMLElement, fileUrl: string): Promise { const PDFObject = await import('pdfobject'); // TODO: the PDFObject library does not support dynamic height adjustment, + // TODO: it seems that this render must be an inplace render, because the URL must be accessible from the current context container.style.height = `${window.innerHeight - 100}px`; if (!PDFObject.default.embed(fileUrl, container)) { throw new Error('Unable to render the PDF file'); diff --git a/web_src/js/render/swagger.ts b/web_src/js/render/swagger.ts new file mode 100644 index 00000000000..27e678f34bf --- /dev/null +++ b/web_src/js/render/swagger.ts @@ -0,0 +1,54 @@ +// AVOID importing other unneeded main site JS modules to prevent unnecessary code and dependencies and chunks. +// This module is used by both the Gitea API page and the frontend external render. +// It doesn't need any code from main site's modules (at the moment). + +import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; +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); +} + +export async function initSwaggerUI(container: HTMLElement, opts: {specText: string}): Promise { + // swagger-ui has built-in dark mode triggered by html.dark-mode class + syncDarkModeClass(); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkModeClass); + + let spec: any; + const specText = opts.specText.trim(); + if (specText.startsWith('{')) { + spec = JSON.parse(specText); + } else { + spec = loadYaml(specText); + } + + // 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, + domNode: container, + deepLinking: window.location.protocol !== 'about:', // pushState fails inside about:srcdoc iframes + 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 index b2f6a61030a..f7a852098a2 100644 --- a/web_src/js/swagger.ts +++ b/web_src/js/swagger.ts @@ -1,70 +1,14 @@ -// 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. - +// FIXME: INCORRECT-VITE-MANIFEST-PARSER: it just happens to work for current dependencies +// If this module depends on another one and that one imports "swagger.css", then {{AssetURI "css/swagger.css"}} won't work 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'; +import {initSwaggerUI} from './render/swagger.ts'; -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')!; +async function initGiteaAPIViewer() { + 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, - ], - }); + const res = await fetch(url); // eslint-disable-line no-restricted-globals + // HINT: SWAGGER-CSS-IMPORT: this is used in the standalone page which already has the related CSS imported by `` + await initSwaggerUI(elSwaggerUi, {specText: await res.text()}); } -initSwaggerUI(); +initGiteaAPIViewer();