初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import {renderAnsi} from './ansi.ts';
|
||||
|
||||
test('renderAnsi', () => {
|
||||
expect(renderAnsi('abc')).toEqual('abc');
|
||||
expect(renderAnsi('abc\n')).toEqual('abc');
|
||||
expect(renderAnsi('abc\r\n')).toEqual('abc');
|
||||
expect(renderAnsi('\r')).toEqual('');
|
||||
expect(renderAnsi('\rx\rabc')).toEqual('x\nabc');
|
||||
expect(renderAnsi('\rabc\rx\r')).toEqual('abc\nx');
|
||||
expect(renderAnsi('\x1b[30mblack\x1b[37mwhite')).toEqual('<span class="ansi-black-fg">black</span><span class="ansi-white-fg">white</span>'); // unclosed
|
||||
expect(renderAnsi('<script>')).toEqual('<script>');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2Ktest\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2K\rtest\r\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2Ktest\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2K\rtest\r\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
|
||||
// treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally.
|
||||
expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc');
|
||||
expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`<span style="background-color:rgb(135,0,0)">a</span><span style="background-color:rgb(175,255,255)">b</span>`);
|
||||
|
||||
// URLs in ANSI output become clickable links
|
||||
const link = (url: string) => `<a href="${url}" target="_blank">${url}</a>`;
|
||||
expect(renderAnsi('Downloading https://github.com/actions/upload-artifact/releases')).toEqual(`Downloading ${link('https://github.com/actions/upload-artifact/releases')}`);
|
||||
expect(renderAnsi('\x1b[32mhttps://proxy.golang.org/cached-only\x1b[0m')).toEqual(`<span class="ansi-green-fg">${link('https://proxy.golang.org/cached-only')}</span>`);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {AnsiUp} from 'ansi_up';
|
||||
import {linkifyURLs} from '../utils/url.ts';
|
||||
|
||||
const replacements: Array<[RegExp, string]> = [
|
||||
[/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op
|
||||
[/\x1b\[\d?[JK]/g, '\r'], // Erase display/line, treat them as a Carriage Return
|
||||
];
|
||||
|
||||
// render ANSI to HTML
|
||||
export function renderAnsi(line: string): string {
|
||||
// create a fresh ansi_up instance because otherwise previous renders can influence
|
||||
// the output of future renders, because ansi_up is stateful and remembers things like
|
||||
// unclosed opening tags for colors.
|
||||
const ansi_up = new AnsiUp();
|
||||
ansi_up.use_classes = true;
|
||||
|
||||
if (line.endsWith('\r\n')) {
|
||||
line = line.substring(0, line.length - 2);
|
||||
} else if (line.endsWith('\n')) {
|
||||
line = line.substring(0, line.length - 1);
|
||||
}
|
||||
|
||||
if (line.includes('\x1b')) {
|
||||
for (const [regex, replacement] of replacements) {
|
||||
line = line.replace(regex, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
let result: string;
|
||||
if (!line.includes('\r')) {
|
||||
result = ansi_up.ansi_to_html(line);
|
||||
} else {
|
||||
// handle "\rReading...1%\rReading...5%\rReading...100%",
|
||||
// convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%"
|
||||
const lines: Array<string> = [];
|
||||
for (const part of line.split('\r')) {
|
||||
if (part === '') continue;
|
||||
const partHtml = ansi_up.ansi_to_html(part);
|
||||
if (partHtml !== '') {
|
||||
lines.push(partHtml);
|
||||
}
|
||||
}
|
||||
// the log message element is with "white-space: break-spaces;", so use "\n" to break lines
|
||||
result = lines.join('\n');
|
||||
}
|
||||
|
||||
return linkifyURLs(result);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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;
|
||||
canHandle: (filename: string, mimeType: string) => boolean;
|
||||
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>;
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
// But it can't share the same CSS file with the standalone page: it triggers our Vite manifest parser's bug
|
||||
// Although single top-level "await import(css)" can work, it requires es2022.
|
||||
// Otherwise, single function-level "await import(css)" can't work due to Vite's dependency analysis and bundling.
|
||||
import '../../../css/swagger-render.css';
|
||||
|
||||
export const frontendRender: FrontendRenderFunc = async (opts): Promise<boolean> => {
|
||||
try {
|
||||
await import('../../../css/swagger-render.css');
|
||||
await initSwaggerUI(opts.container, {specText: opts.contentString()});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {InplaceRenderPlugin} from '../plugin.ts';
|
||||
|
||||
export function newInplacePluginPdfViewer(): InplaceRenderPlugin {
|
||||
return {
|
||||
name: 'pdf-viewer',
|
||||
|
||||
canHandle(filename: string, _mimeType: string): boolean {
|
||||
return filename.toLowerCase().endsWith('.pdf');
|
||||
},
|
||||
|
||||
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');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user