Frontend iframe renderer framework: 3D models, OpenAPI (#37233)

Introduces a frontend external-render framework that runs renderer
plugins inside an `iframe` (loaded via `srcdoc` to keep the CSP
`sandbox` directive working without origin-related console noise), and
migrates the 3D viewer and OpenAPI/Swagger renderers onto it. PDF and
asciicast paths are refactored to share the same `data-render-name`
mechanism.

Adds e2e coverage for 3D, PDF, asciicast and OpenAPI render paths, plus
a regression for the `RefTypeNameSubURL` double-escape on non-ASCII
branch names.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-04-18 00:30:17 +02:00
committed by GitHub
parent 0161f3019b
commit d5831b9385
32 changed files with 540 additions and 293 deletions

View File

@@ -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<void>;
};
export type FrontendRenderOptions = {
container: HTMLElement;
treePath: string;
contentString(): string;
contentBytes(): Uint8Array<ArrayBuffer>;
};
export type FrontendRenderFunc = (opts: FrontendRenderOptions) => Promise<boolean>;

View File

@@ -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<void> {
// 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]);
},
};
}

View File

@@ -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<boolean> => {
try {
await initSwaggerUI(opts.container, {specText: opts.contentString()});
return true;
} catch (error) {
console.error(error);
return false;
}
};

View File

@@ -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<boolean> => {
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;
}
};

View File

@@ -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<void> {
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');

View File

@@ -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<void> {
// 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,
],
});
}