snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:41:59 +05:00
commit 52ae0c5802
6280 changed files with 624193 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
import {AnsiLineRenderer} from './ansi.ts';
test('renderAnsi', () => {
const renderAnsi = (line: string, ansi = new AnsiLineRenderer()) => {
const el = document.createElement('div');
ansi.renderLine(el, line);
return el.innerHTML;
};
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('&lt;script&gt;');
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('foo https://example.com bar')).toEqual(`foo ${link('https://example.com')} bar`);
expect(renderAnsi('<https://example.com?a=b&c=d#h>')).toEqual(`&lt;${link('https://example.com?a=b&amp;c=d#h')}&gt;`);
expect(renderAnsi('open https://example.com.')).toEqual(`open ${link('https://example.com')}.`);
expect(renderAnsi('"https://example.com"')).toEqual(`"${link('https://example.com')}"`);
expect(renderAnsi('\x1b[32mhttps://example.com\x1b[0m')).toEqual(`<span class="ansi-green-fg">${link('https://example.com')}</span>`);
// attributes, faint nesting so its color mixes with the outer one, conceal emitting no foreground
expect(renderAnsi('\x1b[1;5;8mx')).toEqual('<span class="ansi-bold ansi-blink ansi-conceal">x</span>');
expect(renderAnsi('\x1b[6mx')).toEqual('<span class="ansi-blink">x</span>'); // 6 is the rapid blink
expect(renderAnsi('\x1b[2;31mx')).toEqual('<span class="ansi-red-fg"><span class="ansi-faint">x</span></span>');
expect(renderAnsi('\x1b[31;7mx')).toEqual('<span class="ansi-inverse-fg ansi-red-bg">x</span>');
expect(renderAnsi('\x1b[4:3;9;53mx')).toEqual('<span class="ansi-underline ansi-line-through ansi-overline ansi-wavy">x</span>');
expect(renderAnsi('\x1b[4;58;5;9mx')).toEqual('<span class="ansi-underline" style="text-decoration-color: var(--color-ansi-bright-red);">x</span>');
// a color as ":" sub-parameters, with and without a color space id, not consuming the codes after
expect(renderAnsi('\x1b[38:2::255:0:0ma\x1b[48:2:0:0:255mb')).toEqual('<span style="color: rgb(255, 0, 0);">a</span><span style="color: rgb(255, 0, 0); background-color: rgb(0, 0, 255);">b</span>');
expect(renderAnsi('\x1b[1;38:5:9;4mx')).toEqual('<span class="ansi-bold ansi-underline ansi-bright-red-fg">x</span>');
// a private CSI carries no style, even ending in "m", and does not split the run around it
expect(renderAnsi('\x1b[31mred\x1b[>4;2m!')).toEqual('<span class="ansi-red-fg">red!</span>');
// OSC 8 hyperlinks
expect(renderAnsi('\x1b]8;;https://example.com\x1b\\text\x1b]8;;\x1b\\')).toEqual(`<a href="https://example.com" target="_blank">text</a>`);
// only an "http(s)" hyperlink renders as one, and nothing in a log can become markup or an attribute
expect(renderAnsi('\x1b]8;;javascript:alert(1)\x1b\\text\x1b]8;;\x1b\\')).toEqual('text');
expect(renderAnsi('\x1b]8;;data:text/html,<script>\x1b\\text\x1b]8;;\x1b\\')).toEqual('text');
expect(renderAnsi('\x1b]8;;https://x" onmouseover="alert(1)\x07<img src=x>\x1b]8;;\x07')).toEqual(`<a href="https://x&quot; onmouseover=&quot;alert(1)" target="_blank">&lt;img src=x&gt;</a>`);
// a style inside the label renders instead of leaking, and one left open ends with the line
expect(renderAnsi('\x1b]8;id=1;https://example.com\x07\x1b[31mred\x1b[0m\x1b]8;;\x07')).toEqual(`<a href="https://example.com" target="_blank"><span class="ansi-red-fg">red</span></a>`);
expect(renderAnsi('\x1b]8;;https://example.com\x07https://other.com')).toEqual(`<a href="https://example.com" target="_blank">https://other.com</a>`);
// sequences with no visual representation are dropped whole, payload and all, an unterminated one
// up to the next escape or the line end
expect(renderAnsi('\x1bPfoo\x1b\\a\x1b_G1;2\x1b\\b\x1b^priv\x1b\\c\x1bXsos\x1b\\d')).toEqual('abcd');
expect(renderAnsi('\x1b(Ba\x1b#8b\x1b7c\x1b8d\x1b]0;unterminated')).toEqual('abcd');
expect(renderAnsi('\x1b]0;unterminated\x1b\x1b[31mred')).toEqual('<span class="ansi-red-fg">red</span>');
// a string sequence also ends at the 8-bit ST, which a runner may emit instead of "\x1b\\"
expect(renderAnsi('\x1b]0;title\x9cvisible')).toEqual('visible');
expect(renderAnsi('\x1b]8;;https://example.com\x9ctext\x1b]8;;\x9c')).toEqual(`<a href="https://example.com" target="_blank">text</a>`);
// an OSC 11 background color query, a CSI window title push and an OSC 2 title
expect(renderAnsi('\x1b]11;?\x1b\\\x1b[22;2t\x1b]2;🟡 a title\x1b\\go: downloading')).toEqual('go: downloading');
// control characters never reach the output, a backspace moves the cursor back one column
expect(renderAnsi('a\x07b\x00c\x7fd\x9be')).toEqual('abcde');
expect(renderAnsi('Reading... 10%\b\b\b100%')).toEqual('Reading... 100%');
expect(renderAnsi('abc\b\bx')).toEqual('axc');
expect(renderAnsi('\b🟡\bx')).toEqual('x');
// a character a terminal never shows takes no column, and the cursor reaches back over a style
// change, so what it lands on keeps the newer style
expect(renderAnsi('ab\x01c\b\bZ')).toEqual('aZc');
expect(renderAnsi('abc\x1b[31m\b\bx')).toEqual('a<span class="ansi-red-fg">x</span>c');
// a sequence cut off by the line end is dropped, and style carries on to the next line
const ansi = new AnsiLineRenderer();
expect(renderAnsi('\x1b[31mred\x1b[38;5;', ansi)).toEqual('<span class="ansi-red-fg">red</span>');
expect(renderAnsi('still red', ansi)).toEqual('<span class="ansi-red-fg">still red</span>');
});

273
web_src/js/render/ansi.ts Normal file
View File

@@ -0,0 +1,273 @@
import {trimUrlPunctuation, urlRawRegex} from '../utils/url.ts';
import {createElementFromAttrs} from '../utils/dom.ts';
import {colord} from 'colord';
// erase display/line, treated as a carriage return
const eraseInLine = /\x1b\[\d?[JK]/g;
// a CSI, an OSC 8 hyperlink, any other string sequence (OSC, DCS, SOS, PM, APC), then an escape with
// its intermediates. Only SGR ("m") and OSC 8 render, the rest are matched so they can be dropped.
// A string sequence ends at BEL, "\x1b\\", the 8-bit ST "\x9c", or the next escape.
const escapeSequence = /\x1b\[([0-9;:?<=>]*)[\x20-\x2f]*([\x40-\x7e])|\x1b\]8;[^;\x07\x1b\x9c]*;([^\x07\x1b\x9c]*)(?:\x07|\x1b\\|\x9c)|\x1b[\]P^_X][^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?|\x1b[\x20-\x2f]*[\x30-\x5a\x5c-\x7e]/g;
const hyperlinkUrl = /^https?:\/\//i;
// a CSI marked private carries no SGR, whatever its final byte
const privateParams = /^[<=>?]/;
// characters a terminal never shows, other than tab, "\n" and "\r"
const controlChars = /[\x00-\x08\v\f\x0e-\x1f\x7f-\x9f]/g;
const hasControlChar = new RegExp(controlChars.source);
// the same less the backspace, which the column model consumes
const controlCharsNoBackspace = /[\x00-\x07\v\f\x0e-\x1f\x7f-\x9f]/g;
// a line with none of these is plain text, an escape being a control character
const needsRendering = new RegExp(`${controlChars.source}|\\r|://`);
// "4:1" to "4:5" select a style, "4:0" is off. Indexed by number, so a non-numeric one cannot
// reach an inherited property.
const underlineStyles = ['', 'solid', 'double', 'wavy', 'dotted', 'dashed'];
// a css class for the 16 named colors a theme restyles, or "#rrggbb" for the rest, which it must not
type AnsiColor = string;
const anchor = (href: string, ...children: string[]) =>
createElementFromAttrs<HTMLAnchorElement>('a', {href, target: '_blank'}, ...children);
// appends text, turning any bare url inside it into a link
function appendText(target: ParentNode, text: string, linkify: boolean): void {
if (!linkify || !text.includes('://')) {
if (target.firstChild) target.append(text); else target.textContent = text; // one step into an empty target
return;
}
const urls = urlRawRegex();
let pos = 0;
for (let match = urls.exec(text); match; match = urls.exec(text)) {
const url = trimUrlPunctuation(match[0]);
if (match.index > pos) target.append(text.slice(pos, match.index));
target.append(anchor(url, url));
urls.lastIndex = pos = match.index + url.length;
}
if (pos < text.length) target.append(text.slice(pos));
}
type AnsiStyle = {
fg: AnsiColor | null, bg: AnsiColor | null, underlineColor: AnsiColor | null,
underline: string,
bold: boolean, faint: boolean, italic: boolean, blink: boolean,
strikethrough: boolean, overline: boolean, inverse: boolean, conceal: boolean,
};
const ansiStyleInitial: Readonly<AnsiStyle> = {
fg: null, bg: null, underlineColor: null, underline: '',
bold: false, faint: false, italic: false, blink: false,
strikethrough: false, overline: false, inverse: false, conceal: false,
};
// 0-7 normal, 8-15 bright, 16-231 a 6x6x6 rgb cube, 232-255 grayscale, the values the
// ".term-fgx*" rules hardcode for the console renderer
const colorNames = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
const cubeLevels = [0, 95, 135, 175, 215, 255];
const palette256: AnsiColor[] = [
...['ansi-', 'ansi-bright-'].flatMap((prefix) => colorNames.map((name) => `${prefix}${name}`)),
...cubeLevels.flatMap((r) => cubeLevels.flatMap((g) => cubeLevels.map((b) => colord({r, g, b}).toHex()))),
...Array.from({length: 24}, (_value, idx) => colord({r: 8 + idx * 10, g: 8 + idx * 10, b: 8 + idx * 10}).toHex()),
];
// 6 is the rapid blink a terminal renders no differently from the slow blink of 5
const sgrFields: Record<number, Partial<AnsiStyle>> = {
1: {bold: true}, 2: {faint: true}, 3: {italic: true}, 5: {blink: true}, 6: {blink: true}, 7: {inverse: true},
8: {conceal: true}, 9: {strikethrough: true}, 21: {bold: false}, 22: {bold: false, faint: false},
23: {italic: false}, 24: {underline: ''}, 25: {blink: false}, 27: {inverse: false},
28: {conceal: false}, 29: {strikethrough: false}, 39: {fg: null}, 49: {bg: null},
53: {overline: true}, 55: {overline: false}, 59: {underlineColor: null},
};
function applySgr(style: Readonly<AnsiStyle>, params: string): Readonly<AnsiStyle> {
if (params === '' || params === '0') return ansiStyleInitial; // the most common sequence by far
const next = {...style};
const codes = params.split(';');
for (let idx = 0; idx < codes.length; idx++) {
const code = parseInt(codes[idx], 10); // parseInt stops at a ":" sub-parameter on its own
if (isNaN(code) || code === 0) {
Object.assign(next, ansiStyleInitial);
} else if (sgrFields[code]) Object.assign(next, sgrFields[code]);
else if (code === 4) {
// a colon sub-parameter selects the style, as in "4:3" for a curly underline and "4:0" for off
const colon = codes[idx].indexOf(':');
next.underline = colon === -1 ? 'solid' : underlineStyles[Number(codes[idx].slice(colon + 1))] ?? 'solid';
} else if (code >= 30 && code < 38) next.fg = palette256[code - 30];
else if (code >= 40 && code < 48) next.bg = palette256[code - 40];
else if (code >= 90 && code < 98) next.fg = palette256[code - 82]; // 8 + code - 90
else if (code >= 100 && code < 108) next.bg = palette256[code - 92]; // 8 + code - 100
else if (code === 38 || code === 48 || code === 58) {
// "5;<index>" picks from the palette, "2;<r>;<g>;<b>" is truecolor, 58 colors the underline,
// and ":" sub-parameters carry the same with "2" optionally preceded by a color space id
if (codes[idx].includes(':')) {
const sub = codes[idx].split(':');
if (sub.length === 6 && sub[1] === '2') sub.splice(2, 1);
codes.splice(idx, 1, ...sub);
}
// one running off the end consumes only the mode
const mode = codes[++idx];
let color: AnsiColor | null = null;
if (mode === '5' && idx + 1 < codes.length) {
const paletteIndex = parseInt(codes[++idx], 10);
if (paletteIndex >= 0 && paletteIndex <= 255) color = palette256[paletteIndex];
} else if (mode === '2' && idx + 3 < codes.length) {
const [r, g, b] = [codes[++idx], codes[++idx], codes[++idx]].map((value) => parseInt(value, 10));
if (Math.min(r, g, b) >= 0 && Math.max(r, g, b) <= 255) color = colord({r, g, b}).toHex();
}
if (color) next[code === 38 ? 'fg' : code === 48 ? 'bg' : 'underlineColor'] = color;
}
}
return next;
}
type AnsiRun = {text: string, style: Readonly<AnsiStyle>};
/** Replays the backspaces over a part, so what follows a cursor step back overwrites what sits
* there, keeping the style that wrote it rather than the one it displaced. */
function overwriteColumns(runs: AnsiRun[]): AnsiRun[] {
const columns: string[] = [];
const styles: Array<Readonly<AnsiStyle>> = [];
let col = 0;
for (const run of runs) {
for (const char of run.text.replace(controlCharsNoBackspace, '')) { // by code point, so a pair stays whole
if (char === '\b') col = Math.max(col - 1, 0);
else {
columns[col] = char;
styles[col] = run.style;
col++;
}
}
}
const merged: AnsiRun[] = [];
for (let idx = 0; idx < columns.length; idx++) {
if (idx && styles[idx] === styles[idx - 1]) merged[merged.length - 1].text += columns[idx];
else merged.push({text: columns[idx], style: styles[idx]});
}
return merged;
}
function renderText(target: ParentNode, text: string, style: Readonly<AnsiStyle>, linkify = true): void {
if (hasControlChar.test(text)) text = text.replace(controlChars, '');
if (text === '') return;
const classes: string[] = [];
// the one place deciding class vs inline, a named color having a class per slot
const colorValue = (color: AnsiColor | null, slot?: string) => {
if (!color) {
if (slot && style.inverse) classes.push(`ansi-inverse-${slot}`);
return '';
}
if (color[0] === '#') return color; // a literal color is never themed
if (!slot) return `var(--color-${color})`; // a decoration color has no class of its own
classes.push(`${color}-${slot}`);
return '';
};
if (style.bold) classes.push('ansi-bold');
if (style.italic) classes.push('ansi-italic');
if (style.blink) classes.push('ansi-blink');
if (style.conceal) classes.push('ansi-conceal');
if (style.underline) classes.push('ansi-underline');
if (style.strikethrough) classes.push('ansi-line-through');
if (style.overline) classes.push('ansi-overline');
if (style.underline && style.underline !== 'solid') classes.push(`ansi-${style.underline}`);
const decorated = style.underline || style.strikethrough || style.overline;
const decorationColor = style.underlineColor && decorated ? colorValue(style.underlineColor) : '';
// inverse swaps the two, terminal defaults included, and conceal emits no foreground at all so
// that an inline color can never outrank the concealing class
const color = style.conceal ? '' : colorValue(style.inverse ? style.bg : style.fg, 'fg');
const background = colorValue(style.inverse ? style.fg : style.bg, 'bg');
if (classes.length || decorationColor || color || background) {
const span = document.createElement('span');
if (classes.length) span.className = classes.join(' ');
// each declaration on its own, so no string from a log can widen what it applies to
if (decorationColor) span.style.textDecorationColor = decorationColor;
if (color) span.style.color = color;
if (background) span.style.backgroundColor = background;
target.append(span);
target = span;
}
if (style.faint) { // nested, so its translucent color mixes with the color the outer span applies
const faint = document.createElement('span');
faint.className = 'ansi-faint';
target.append(faint);
target = faint;
}
appendText(target, text, linkify);
}
/** Renders one log stream, carrying the style between lines the way a terminal does but never an
* escape sequence. Each stream owns an instance. */
export class AnsiLineRenderer {
private style: Readonly<AnsiStyle> = ansiStyleInitial;
private renderPart(target: ParentNode, part: string): void {
const overwriting = part.includes('\b'); // a backspace reaches back, so nothing renders until the end
if (!overwriting && !part.includes('\x1b')) {
renderText(target, part, this.style);
return;
}
let pos = 0;
let pending = ''; // text is held until the style changes, so an escape between runs cannot split them
let container: ParentNode = target; // an open OSC 8 hyperlink, collecting the styled text
const runs: AnsiRun[] = [];
const flush = () => {
if (pending) runs.push({text: pending, style: this.style});
pending = '';
};
const emit = () => {
// a run inside a hyperlink is not linkified again
for (const run of overwriting ? overwriteColumns(runs) : runs) renderText(container, run.text, run.style, container === target);
runs.length = 0;
};
escapeSequence.lastIndex = 0; // the regex is reused, so its match position must be reset
for (let match = escapeSequence.exec(part); match; match = escapeSequence.exec(part)) {
if (match.index > pos) pending += part.slice(pos, match.index);
pos = match.index + match[0].length;
const [, params, final, url] = match; // see the group order on escapeSequence
if (final === 'm' && !privateParams.test(params)) {
flush();
this.style = applySgr(this.style, params);
} else if (url !== undefined) { // an OSC 8, with an empty url when it closes a hyperlink
flush();
emit(); // a hyperlink is an element of its own, so a backspace cannot reach back across it
// any scheme but http(s) renders as plain text, and a hyperlink never spills past the part
const link = hyperlinkUrl.test(url) ? anchor(url) : null;
if (link) target.append(link);
container = link ?? target;
}
}
const cutOff = part.indexOf('\x1b', pos); // a leftover escape is a sequence cut off by the line end
pending += part.slice(pos, cutOff === -1 ? part.length : cutOff);
flush();
emit();
}
renderLine(el: HTMLElement, line: string): void {
if (line.endsWith('\n')) line = line.slice(0, line.endsWith('\r\n') ? -2 : -1);
// a plain line inheriting no style skips the parser entirely
if (this.style === ansiStyleInitial && !needsRendering.test(line)) {
el.textContent = line;
return;
}
if (line.includes('\x1b')) line = line.replace(eraseInLine, '\r');
if (el.firstChild) el.replaceChildren(); // nothing to clear for the fresh element a log line owns
if (!line.includes('\r')) {
this.renderPart(el, line); // no fragment, so the nodes are never built only to be moved
return;
}
// one part per update, separated by "\n" for "white-space: break-spaces"
for (const part of line.split('\r')) {
if (!part) continue;
const previous = el.lastChild;
this.renderPart(el, part);
// a part that rendered nothing needs no separator
if (previous && previous !== el.lastChild) el.insertBefore(document.createTextNode('\n'), previous.nextSibling);
}
}
}

View File

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

View File

@@ -0,0 +1,23 @@
import type {FrontendRenderFunc} from '../plugin.ts';
export const frontendRender: FrontendRenderFunc = async (opts): Promise<boolean> => {
try {
const [player] = await Promise.all([
import('asciinema-player'),
import('asciinema-player/dist/bundle/asciinema-player.css'),
]);
player.create({data: opts.contentString()}, opts.container, {
// poster (a preview frame) to display until the playback is started.
// Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more.
poster: 'npt:1:0:0',
});
// Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js : <div class="ap-term" ...>
// Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159
opts.container.querySelector<HTMLElement>('.ap-term')!.style.overflow = 'hidden';
opts.container.querySelector<HTMLElement>('.ap-player')!.style.borderRadius = '0';
return true;
} catch (error) {
console.error(error);
return false;
}
};

View File

@@ -0,0 +1,15 @@
import type {FrontendRenderFunc} from '../plugin.ts';
import {initSwaggerUI} from '../swagger.ts';
// HINT: SWAGGER-CSS-IMPORT: these styles are for the render only.
import '../../../css/swagger-render.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

@@ -0,0 +1,19 @@
import type {InplaceRenderPlugin} from '../plugin.ts';
export function newInplacePluginPdfViewer(): InplaceRenderPlugin {
return {
name: 'pdf-viewer',
canHandle: (filename: string, _mimeType: string): boolean => 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');
}
},
};
}

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,
],
});
}