mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-06 04:01:05 +09:00
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:
@@ -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 {
|
||||
|
||||
67
web_src/js/external-render-frontend.ts
Normal file
67
web_src/js/external-render-frontend.ts
Normal file
@@ -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<string, LazyLoadFunc> = {
|
||||
'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<ArrayBuffer> | 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<ArrayBuffer> {
|
||||
return Uint8Array.from(atob(this.rawString), (c) => c.charCodeAt(0));
|
||||
}
|
||||
contentBytes(): Uint8Array<ArrayBuffer> {
|
||||
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<HTMLElement>('#frontend-render-viewer')!;
|
||||
const renderNames = viewerContainer.getAttribute('data-frontend-renders')!.split(' ');
|
||||
const fileTreePath = viewerContainer.getAttribute('data-file-tree-path')!;
|
||||
|
||||
const fileDataElem = document.querySelector<HTMLTextAreaElement>('#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();
|
||||
@@ -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<string, any> = {}) => {
|
||||
|
||||
@@ -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<HTMLElement>('.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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
17
web_src/js/render/plugins/frontend-openapi-swagger.ts
Normal file
17
web_src/js/render/plugins/frontend-openapi-swagger.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
36
web_src/js/render/plugins/frontend-viewer-3d.ts
Normal file
36
web_src/js/render/plugins/frontend-viewer-3d.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
54
web_src/js/render/swagger.ts
Normal file
54
web_src/js/render/swagger.ts
Normal 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,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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<HTMLElement>('#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,
|
||||
],
|
||||
});
|
||||
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 `<link>`
|
||||
await initSwaggerUI(elSwaggerUi, {specText: await res.text()});
|
||||
}
|
||||
|
||||
initSwaggerUI();
|
||||
initGiteaAPIViewer();
|
||||
|
||||
Reference in New Issue
Block a user