0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-20 23:09:04 +02:00

Add frontend render plugin system support

This commit is contained in:
Lunny Xiao 2025-12-05 20:01:56 -08:00
parent c287a8cdb5
commit 2c56b90cd4
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
32 changed files with 2174 additions and 18 deletions

View File

@ -0,0 +1,51 @@
# Go + WASM Render Plugin Example
This example shows how to build a frontend render plugin whose heavy lifting
runs inside a WebAssembly module compiled from Go. The plugin loads the WASM
binary in the browser, asks Go to post-process the fetched file content, and
then renders the result inside the file viewer.
## Files
- `manifest.json` — plugin metadata consumed by the Gitea backend
- `render.js` — the ES module entry point that initializes the Go runtime and
renders files handled by the plugin
- `wasm/` — contains the Go source that compiles to `plugin.wasm`
- `wasm_exec.js` — the Go runtime shim required by all Go-generated WASM
binaries (copied verbatim from the Go distribution)
- `build.sh` — helper script that builds `plugin.wasm` and produces a zip
archive ready for upload
## Build & Install
1. Build the WASM binary and zip archive:
```bash
cd contrib/render-plugins/example-wasm
./build.sh
```
The script requires Go 1.21+ on your PATH. It stores the compiled WASM and
an installable `example-go-wasm.zip` under `dist/`.
2. In the Gitea web UI, visit `Site Administration → Render Plugins`, upload
`dist/example-go-wasm.zip`, and enable the plugin.
3. Open any file whose name ends with `.wasmnote`; the viewer will show the
processed output from the Go code running inside WebAssembly.
## How It Works
- `wasm/main.go` exposes a single `wasmProcessFile` function to JavaScript. It
uppercases each line, prefixes it with the line number, and runs entirely
inside WebAssembly compiled from Go.
- `render.js` injects the Go runtime (`wasm_exec.js`), instantiates the compiled
module, and caches the exported `wasmProcessFile` function.
- During initialization the frontend passes the sniffed MIME type and the first
1 KiB of file data to the plugin (`options.mimeType`/`options.headChunk`),
allowing renderers to make decisions without issuing extra network requests.
- During rendering the plugin downloads the target file, passes the contents to
Go, and displays the transformed text with minimal styling.
Feel free to modify the Go source or the JS wrapper to experiment with richer
interfaces between JavaScript and WebAssembly.

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
BUILD_DIR="$SCRIPT_DIR/.build"
DIST_DIR="$SCRIPT_DIR/dist"
ARCHIVE_NAME="example-go-wasm.zip"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$DIST_DIR"
export GOOS=js
export GOARCH=wasm
echo "[+] Building Go WASM binary..."
go build -o "$BUILD_DIR/plugin.wasm" ./wasm
cp manifest.json "$BUILD_DIR/"
cp render.js "$BUILD_DIR/"
cp wasm_exec.js "$BUILD_DIR/"
( cd "$BUILD_DIR" && zip -q "../dist/$ARCHIVE_NAME" manifest.json render.js wasm_exec.js plugin.wasm )
echo "[+] Wrote $DIST_DIR/$ARCHIVE_NAME"

View File

@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"id": "example-go-wasm",
"name": "Example Go WASM Renderer",
"version": "0.1.0",
"description": "Demonstrates calling a Go-compiled WebAssembly module inside a Gitea render plugin.",
"entry": "render.js",
"filePatterns": [
"*.wasmnote"
]
}

View File

@ -0,0 +1,163 @@
const wasmUrl = new URL('plugin.wasm', import.meta.url);
const wasmExecUrl = new URL('wasm_exec.js', import.meta.url);
let wasmBridgePromise;
let styleInjected = false;
function injectScriptOnce(url) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-go-runtime="${url.href}"]`);
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve();
} else {
existing.addEventListener('load', resolve, {once: true});
existing.addEventListener('error', reject, {once: true});
}
return;
}
const script = document.createElement('script');
script.dataset.goRuntime = url.href;
script.src = url.href;
script.async = true;
script.addEventListener('load', () => {
script.dataset.loaded = 'true';
resolve();
}, {once: true});
script.addEventListener('error', reject, {once: true});
document.head.appendChild(script);
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForExport(name, timeoutMs = 2000) {
const start = Date.now();
while (typeof globalThis[name] !== 'function') {
if (Date.now() - start > timeoutMs) {
throw new Error(`Go runtime did not expose ${name} within ${timeoutMs}ms`);
}
await sleep(20);
}
return globalThis[name];
}
async function ensureWasmBridge() {
if (!wasmBridgePromise) {
wasmBridgePromise = (async () => {
if (typeof globalThis.Go === 'undefined') {
await injectScriptOnce(wasmExecUrl);
}
if (typeof globalThis.Go === 'undefined') {
throw new Error('Go runtime (wasm_exec.js) is unavailable');
}
const go = new globalThis.Go();
let result;
const fetchRequest = fetch(wasmUrl);
if (WebAssembly.instantiateStreaming) {
try {
result = await WebAssembly.instantiateStreaming(fetchRequest, go.importObject);
} catch (err) {
console.warn('instantiateStreaming failed; falling back to ArrayBuffer', err);
const buffer = await (await fetchRequest).arrayBuffer();
result = await WebAssembly.instantiate(buffer, go.importObject);
}
} else {
const buffer = await (await fetchRequest).arrayBuffer();
result = await WebAssembly.instantiate(buffer, go.importObject);
}
go.run(result.instance);
const processFile = await waitForExport('wasmProcessFile');
return {
process(content) {
const output = processFile(content);
return typeof output === 'string' ? output : String(output ?? '');
},
};
})();
}
return wasmBridgePromise;
}
async function fetchFileText(fileUrl) {
const response = await window.fetch(fileUrl, {headers: {'Accept': 'text/plain'}});
if (!response.ok) {
throw new Error(`failed to fetch file (${response.status})`);
}
return response.text();
}
function ensureStyles() {
if (styleInjected) return;
styleInjected = true;
const style = document.createElement('style');
style.textContent = `
.go-wasm-renderer {
font-family: var(--fonts-proportional, system-ui);
border: 1px solid var(--color-secondary);
border-radius: 6px;
overflow: hidden;
}
.go-wasm-renderer__header {
margin: 0;
padding: 0.75rem 1rem;
background: var(--color-secondary-alpha-20);
font-weight: 600;
}
.go-wasm-renderer pre {
margin: 0;
padding: 1rem;
background: var(--color-box-body);
font-family: var(--fonts-monospace, SFMono-Regular, monospace);
white-space: pre;
overflow-x: auto;
}
.go-wasm-renderer__error {
color: var(--color-danger);
}
`;
document.head.appendChild(style);
}
function renderError(container, message) {
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'go-wasm-renderer';
const header = document.createElement('div');
header.className = 'go-wasm-renderer__header';
header.textContent = 'Go WASM Renderer';
const body = document.createElement('pre');
body.className = 'go-wasm-renderer__error';
body.textContent = message;
wrapper.append(header, body);
container.appendChild(wrapper);
}
export default {
name: 'Go WASM Renderer',
async render(container, fileUrl) {
ensureStyles();
try {
const [bridge, content] = await Promise.all([
ensureWasmBridge(),
fetchFileText(fileUrl),
]);
const processed = await bridge.process(content);
const wrapper = document.createElement('div');
wrapper.className = 'go-wasm-renderer';
const header = document.createElement('div');
header.className = 'go-wasm-renderer__header';
header.textContent = 'Go WASM Renderer';
const body = document.createElement('pre');
body.textContent = processed;
wrapper.append(header, body);
container.innerHTML = '';
container.appendChild(wrapper);
} catch (err) {
console.error('Go WASM plugin failed', err);
renderError(container, `Unable to render file: ${err.message}`);
}
},
};

View File

@ -0,0 +1,26 @@
package main
import (
"fmt"
"strings"
"syscall/js"
)
func processFile(this js.Value, args []js.Value) any {
if len(args) == 0 {
return js.ValueOf("(no content)")
}
content := args[0].String()
lines := strings.Split(content, "\n")
var b strings.Builder
b.Grow(len(content) + len(lines)*8)
for i, line := range lines {
fmt.Fprintf(&b, "%4d │ %s\n", i+1, strings.ToUpper(line))
}
return js.ValueOf(b.String())
}
func main() {
js.Global().Set("wasmProcessFile", js.FuncOf(processFile))
select {}
}

View File

@ -0,0 +1,575 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

View File

@ -0,0 +1,30 @@
# Example Frontend Render Plugin
This directory contains a minimal render plugin that highlights `.txt` files
with a custom color scheme. Use it as a starting point for your own plugins or
as a quick way to validate the dynamic plugin system locally.
## Files
- `manifest.json` &mdash; metadata (including the required `schemaVersion`) consumed by Gitea when installing a plugin
- `render.js` &mdash; an ES module that exports a `render(container, fileUrl)`
function; it downloads the source file and renders it in a styled `<pre>`
## Build & Install
1. Create a zip archive that contains both files:
```bash
cd contrib/render-plugins/example
zip -r ../example-highlight-txt.zip manifest.json render.js
```
2. In the Gitea web UI, visit `Site Administration → Render Plugins`, upload
`example-highlight-txt.zip`, and enable it.
3. Open any `.txt` file in a repository; the viewer will display the content in
the custom colors to confirm the plugin is active.
Feel free to modify `render.js` to experiment with the API. The plugin runs in
the browser, so only standard Web APIs are available (no bundler is required
as long as the file stays a plain ES module).

View File

@ -0,0 +1,9 @@
{
"schemaVersion": 1,
"id": "example-highlight-txt",
"name": "Example TXT Highlighter",
"version": "1.0.0",
"description": "Simple sample plugin that renders .txt files with a custom color scheme.",
"entry": "render.js",
"filePatterns": ["*.txt"]
}

View File

@ -0,0 +1,28 @@
const TEXT_COLOR = '#f6e05e';
const BACKGROUND_COLOR = '#1a202c';
async function render(container, fileUrl) {
container.innerHTML = '';
const message = document.createElement('div');
message.className = 'ui tiny message';
message.textContent = 'Rendered by example-highlight-txt plugin';
container.append(message);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to download file (${response.status})`);
}
const text = await response.text();
const pre = document.createElement('pre');
pre.style.backgroundColor = BACKGROUND_COLOR;
pre.style.color = TEXT_COLOR;
pre.style.padding = '1rem';
pre.style.borderRadius = '0.5rem';
pre.style.overflow = 'auto';
pre.textContent = text;
container.append(pre);
}
export default {render};

View File

@ -398,6 +398,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.25.0 ends at migration ID number 322 (database version 323)
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
newMigration(324, "Add frontend render plugin table", v1_26.AddRenderPluginTable),
}
return preparedMigrations
}

View File

@ -0,0 +1,30 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
// AddRenderPluginTable creates the render_plugin table used by the frontend plugin system.
func AddRenderPluginTable(x *xorm.Engine) error {
type RenderPlugin struct {
ID int64 `xorm:"pk autoincr"`
Identifier string `xorm:"UNIQUE NOT NULL"`
Name string `xorm:"NOT NULL"`
Version string `xorm:"NOT NULL"`
Description string `xorm:"TEXT"`
Source string `xorm:"TEXT"`
Entry string `xorm:"NOT NULL"`
FilePatterns []string `xorm:"JSON"`
FormatVersion int `xorm:"NOT NULL DEFAULT 1"`
Enabled bool `xorm:"NOT NULL DEFAULT false"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
}
return x.Sync(new(RenderPlugin))
}

125
models/render/plugin.go Normal file
View File

@ -0,0 +1,125 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package render
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
)
// Plugin represents a frontend render plugin installed on the instance.
type Plugin struct {
ID int64 `xorm:"pk autoincr"`
Identifier string `xorm:"UNIQUE NOT NULL"`
Name string `xorm:"NOT NULL"`
Version string `xorm:"NOT NULL"`
Description string `xorm:"TEXT"`
Source string `xorm:"TEXT"`
Entry string `xorm:"NOT NULL"`
FilePatterns []string `xorm:"JSON"`
FormatVersion int `xorm:"NOT NULL DEFAULT 1"`
Enabled bool `xorm:"NOT NULL DEFAULT false"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
}
func init() {
db.RegisterModel(new(Plugin))
}
// TableName implements xorm's table name convention.
func (Plugin) TableName() string {
return "render_plugin"
}
// ListPlugins returns all registered render plugins ordered by identifier.
func ListPlugins(ctx context.Context) ([]*Plugin, error) {
plugins := make([]*Plugin, 0, 4)
return plugins, db.GetEngine(ctx).Asc("identifier").Find(&plugins)
}
// ListEnabledPlugins returns all enabled render plugins.
func ListEnabledPlugins(ctx context.Context) ([]*Plugin, error) {
plugins := make([]*Plugin, 0, 4)
return plugins, db.GetEngine(ctx).
Where("enabled = ?", true).
Asc("identifier").
Find(&plugins)
}
// GetPluginByID returns the plugin with the given primary key.
func GetPluginByID(ctx context.Context, id int64) (*Plugin, error) {
plug := new(Plugin)
has, err := db.GetEngine(ctx).ID(id).Get(plug)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{ID: id}
}
return plug, nil
}
// GetPluginByIdentifier returns the plugin with the given identifier.
func GetPluginByIdentifier(ctx context.Context, identifier string) (*Plugin, error) {
plug := new(Plugin)
has, err := db.GetEngine(ctx).
Where("identifier = ?", identifier).
Get(plug)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: identifier}
}
return plug, nil
}
// UpsertPlugin inserts or updates the plugin identified by Identifier.
func UpsertPlugin(ctx context.Context, plug *Plugin) error {
return db.WithTx(ctx, func(ctx context.Context) error {
existing := new(Plugin)
has, err := db.GetEngine(ctx).
Where("identifier = ?", plug.Identifier).
Get(existing)
if err != nil {
return err
}
if has {
plug.ID = existing.ID
plug.Enabled = existing.Enabled
plug.CreatedUnix = existing.CreatedUnix
_, err = db.GetEngine(ctx).
ID(existing.ID).
AllCols().
Update(plug)
return err
}
_, err = db.GetEngine(ctx).Insert(plug)
return err
})
}
// SetPluginEnabled toggles plugin enabled state.
func SetPluginEnabled(ctx context.Context, plug *Plugin, enabled bool) error {
if plug.Enabled == enabled {
return nil
}
plug.Enabled = enabled
_, err := db.GetEngine(ctx).
ID(plug.ID).
Cols("enabled").
Update(plug)
return err
}
// DeletePlugin removes the plugin row.
func DeletePlugin(ctx context.Context, plug *Plugin) error {
_, err := db.GetEngine(ctx).
ID(plug.ID).
Delete(new(Plugin))
return err
}

View File

@ -0,0 +1,105 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package renderplugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"code.gitea.io/gitea/modules/util"
)
var identifierRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9\-_.]{1,63}$`)
// Manifest describes the metadata declared by a render plugin.
const SupportedManifestVersion = 1
type Manifest struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Entry string `json:"entry"`
FilePatterns []string `json:"filePatterns"`
}
// Normalize validates mandatory fields and normalizes values.
func (m *Manifest) Normalize() error {
if m.SchemaVersion == 0 {
return fmt.Errorf("manifest schemaVersion is required")
}
if m.SchemaVersion != SupportedManifestVersion {
return fmt.Errorf("manifest schemaVersion %d is not supported", m.SchemaVersion)
}
m.ID = strings.TrimSpace(strings.ToLower(m.ID))
if !identifierRegexp.MatchString(m.ID) {
return fmt.Errorf("manifest id %q is invalid; only lowercase letters, numbers, dash, underscore and dot are allowed", m.ID)
}
m.Name = strings.TrimSpace(m.Name)
if m.Name == "" {
return fmt.Errorf("manifest name is required")
}
m.Version = strings.TrimSpace(m.Version)
if m.Version == "" {
return fmt.Errorf("manifest version is required")
}
if m.Entry == "" {
m.Entry = "render.js"
}
m.Entry = util.PathJoinRelX(m.Entry)
if m.Entry == "" || strings.HasPrefix(m.Entry, "../") {
return fmt.Errorf("manifest entry %q is invalid", m.Entry)
}
cleanPatterns := make([]string, 0, len(m.FilePatterns))
for _, pattern := range m.FilePatterns {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
cleanPatterns = append(cleanPatterns, pattern)
}
if len(cleanPatterns) == 0 {
return fmt.Errorf("manifest must declare at least one file pattern")
}
sort.Strings(cleanPatterns)
m.FilePatterns = cleanPatterns
return nil
}
// LoadManifest reads and validates the manifest.json file located under dir.
func LoadManifest(dir string) (*Manifest, error) {
manifestPath := filepath.Join(dir, "manifest.json")
f, err := os.Open(manifestPath)
if err != nil {
return nil, err
}
defer f.Close()
var manifest Manifest
if err := json.NewDecoder(f).Decode(&manifest); err != nil {
return nil, fmt.Errorf("malformed manifest.json: %w", err)
}
if err := manifest.Normalize(); err != nil {
return nil, err
}
return &manifest, nil
}
// Metadata is the public information exposed to the frontend for an enabled plugin.
type Metadata struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Entry string `json:"entry"`
EntryURL string `json:"entryUrl"`
AssetsBase string `json:"assetsBaseUrl"`
FilePatterns []string `json:"filePatterns"`
SchemaVersion int `json:"schemaVersion"`
}

View File

@ -0,0 +1,32 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package renderplugin
import (
"path"
"code.gitea.io/gitea/modules/storage"
)
// Storage returns the object storage used for render plugins.
func Storage() storage.ObjectStorage {
return storage.RenderPlugins
}
// ObjectPath builds a storage-relative path for a plugin asset.
func ObjectPath(identifier string, elems ...string) string {
joined := path.Join(elems...)
if joined == "." || joined == "" {
return path.Join(identifier)
}
return path.Join(identifier, joined)
}
// ObjectPrefix returns the storage prefix for a plugin identifier.
func ObjectPrefix(identifier string) string {
if identifier == "" {
return ""
}
return identifier + "/"
}

View File

@ -0,0 +1,16 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
type RenderPluginSetting struct {
Storage *Storage
}
var RenderPlugin RenderPluginSetting
func loadRenderPluginFrom(rootCfg ConfigProvider) (err error) {
sec, _ := rootCfg.GetSection("render_plugins")
RenderPlugin.Storage, err = getStorage(rootCfg, "render-plugins", "", sec)
return err
}

View File

@ -138,6 +138,9 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
if err := loadActionsFrom(cfg); err != nil {
return err
}
if err := loadRenderPluginFrom(cfg); err != nil {
return err
}
loadUIFrom(cfg)
loadAdminFrom(cfg)
loadAPIFrom(cfg)

View File

@ -133,6 +133,9 @@ var (
Actions ObjectStorage = uninitializedStorage
// Actions Artifacts represents actions artifacts storage
ActionsArtifacts ObjectStorage = uninitializedStorage
// RenderPlugins represents render plugin storage
RenderPlugins ObjectStorage = uninitializedStorage
)
// Init init the storage
@ -145,6 +148,7 @@ func Init() error {
initRepoArchives,
initPackages,
initActions,
initRenderPlugins,
} {
if err := f(); err != nil {
return err
@ -228,3 +232,9 @@ func initActions() (err error) {
ActionsArtifacts, err = NewStorage(setting.Actions.ArtifactStorage.Type, setting.Actions.ArtifactStorage)
return err
}
func initRenderPlugins() (err error) {
log.Info("Initialising Render Plugin storage with type: %s", setting.RenderPlugin.Storage.Type)
RenderPlugins, err = NewStorage(setting.RenderPlugin.Storage.Type, setting.RenderPlugin.Storage)
return err
}

View File

@ -2991,6 +2991,45 @@ users = User Accounts
organizations = Organizations
assets = Code Assets
repositories = Repositories
render_plugins = Render Plugins
render_plugins.description = Upload, enable, or disable frontend renderers provided as plugins.
render_plugins.upload_label = Plugin Archive (.zip)
render_plugins.install = Install Plugin
render_plugins.example_hint = Example source files are available in contrib/render-plugins/example (zip both files and upload the archive here).
render_plugins.table.name = Name
render_plugins.table.identifier = Identifier
render_plugins.table.version = Version
render_plugins.table.patterns = File Patterns
render_plugins.table.status = Status
render_plugins.table.actions = Actions
render_plugins.empty = No render plugins are installed yet.
render_plugins.enable = Enable
render_plugins.disable = Disable
render_plugins.delete = Delete
render_plugins.delete_confirm = Delete plugin "%s"? All of its files will be removed.
render_plugins.status.enabled = Enabled
render_plugins.status.disabled = Disabled
render_plugins.upload_success = Plugin "%s" installed successfully.
render_plugins.upload_failed = Failed to install plugin: %v
render_plugins.upload_missing = Please choose a plugin archive to upload.
render_plugins.enabled = Plugin "%s" enabled.
render_plugins.disabled = Plugin "%s" disabled.
render_plugins.deleted = Plugin "%s" deleted.
render_plugins.invalid = Unknown plugin request.
render_plugins.upgrade = Upgrade
render_plugins.upgrade_success = Plugin "%s" upgraded to version %s.
render_plugins.upgrade_failed = Failed to upgrade plugin: %v
render_plugins.back_to_list = Back to plugin list
render_plugins.detail_title = Plugin: %s
render_plugins.detail.description = Description
render_plugins.detail.description_empty = No description provided.
render_plugins.detail.format_version = Manifest format version
render_plugins.detail.entry = Entry file
render_plugins.detail.source = Source
render_plugins.detail.none = Not provided
render_plugins.detail.file_patterns_empty = No file patterns declared.
render_plugins.detail.actions = Plugin actions
render_plugins.detail.upgrade = Upgrade plugin
hooks = Webhooks
integrations = Integrations
authentication = Authentication Sources

View File

@ -0,0 +1,165 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"fmt"
"net/http"
"strings"
render_model "code.gitea.io/gitea/models/render"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
plugin_service "code.gitea.io/gitea/services/renderplugin"
)
const (
tplRenderPlugins templates.TplName = "admin/render/plugins"
tplRenderPluginDetail templates.TplName = "admin/render/plugin_detail"
)
// RenderPlugins shows the plugin management page.
func RenderPlugins(ctx *context.Context) {
plugs, err := render_model.ListPlugins(ctx)
if err != nil {
ctx.ServerError("ListPlugins", err)
return
}
ctx.Data["Title"] = ctx.Tr("admin.render_plugins")
ctx.Data["PageIsAdminRenderPlugins"] = true
ctx.Data["Plugins"] = plugs
ctx.HTML(http.StatusOK, tplRenderPlugins)
}
// RenderPluginDetail shows a single plugin detail page.
func RenderPluginDetail(ctx *context.Context) {
plug := mustGetRenderPlugin(ctx)
if plug == nil {
return
}
ctx.Data["Title"] = ctx.Tr("admin.render_plugins.detail_title", plug.Name)
ctx.Data["PageIsAdminRenderPlugins"] = true
ctx.Data["Plugin"] = plug
ctx.HTML(http.StatusOK, tplRenderPluginDetail)
}
// RenderPluginsUpload handles plugin uploads.
func RenderPluginsUpload(ctx *context.Context) {
file, header, err := ctx.Req.FormFile("plugin")
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
redirectRenderPlugins(ctx)
return
}
defer file.Close()
if header.Size == 0 {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_missing"))
redirectRenderPlugins(ctx)
return
}
if _, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, ""); err != nil {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
} else {
ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_success", header.Filename))
}
redirectRenderPlugins(ctx)
}
// RenderPluginsEnable toggles plugin state to enabled.
func RenderPluginsEnable(ctx *context.Context) {
plug := mustGetRenderPlugin(ctx)
if plug == nil {
return
}
if err := plugin_service.SetEnabled(ctx, plug, true); err != nil {
ctx.Flash.Error(err.Error())
} else {
ctx.Flash.Success(ctx.Tr("admin.render_plugins.enabled", plug.Name))
}
redirectRenderPlugins(ctx)
}
// RenderPluginsDisable toggles plugin state to disabled.
func RenderPluginsDisable(ctx *context.Context) {
plug := mustGetRenderPlugin(ctx)
if plug == nil {
return
}
if err := plugin_service.SetEnabled(ctx, plug, false); err != nil {
ctx.Flash.Error(err.Error())
} else {
ctx.Flash.Success(ctx.Tr("admin.render_plugins.disabled", plug.Name))
}
redirectRenderPlugins(ctx)
}
// RenderPluginsDelete removes a plugin entirely.
func RenderPluginsDelete(ctx *context.Context) {
plug := mustGetRenderPlugin(ctx)
if plug == nil {
return
}
if err := plugin_service.Delete(ctx, plug); err != nil {
ctx.Flash.Error(err.Error())
} else {
ctx.Flash.Success(ctx.Tr("admin.render_plugins.deleted", plug.Name))
}
redirectRenderPlugins(ctx)
}
// RenderPluginsUpgrade upgrades an existing plugin with a new archive.
func RenderPluginsUpgrade(ctx *context.Context) {
plug := mustGetRenderPlugin(ctx)
if plug == nil {
return
}
file, header, err := ctx.Req.FormFile("plugin")
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
redirectRenderPlugins(ctx)
return
}
defer file.Close()
if header.Size == 0 {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_missing"))
redirectRenderPlugins(ctx)
return
}
updated, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, plug.Identifier)
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
} else {
ctx.Flash.Success(ctx.Tr("admin.render_plugins.upgrade_success", updated.Name, updated.Version))
}
redirectRenderPlugins(ctx)
}
func mustGetRenderPlugin(ctx *context.Context) *render_model.Plugin {
id := ctx.PathParamInt64("id")
if id <= 0 {
ctx.Flash.Error(ctx.Tr("admin.render_plugins.invalid"))
redirectRenderPlugins(ctx)
return nil
}
plug, err := render_model.GetPluginByID(ctx, id)
if err != nil {
ctx.Flash.Error(fmt.Sprintf("%v", err))
redirectRenderPlugins(ctx)
return nil
}
return plug
}
func redirectRenderPlugins(ctx *context.Context) {
redirectTo := ctx.FormString("redirect_to")
if redirectTo != "" {
base := setting.AppSubURL + "/"
if strings.HasPrefix(redirectTo, base) {
ctx.Redirect(redirectTo)
return
}
}
ctx.Redirect(setting.AppSubURL + "/-/admin/render-plugins")
}

View File

@ -0,0 +1,93 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package renderplugin
import (
"encoding/json"
"net/http"
"os"
"path"
"strings"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/renderplugin"
"code.gitea.io/gitea/modules/setting"
plugin_service "code.gitea.io/gitea/services/renderplugin"
)
// AssetsHandler returns an http.Handler that serves plugin metadata and static files.
func AssetsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
prefix := setting.AppSubURL + "/assets/render-plugins/"
if !strings.HasPrefix(r.URL.Path, prefix) {
w.WriteHeader(http.StatusNotFound)
return
}
rel := strings.TrimPrefix(r.URL.Path, prefix)
rel = strings.TrimLeft(rel, "/")
if rel == "" {
w.WriteHeader(http.StatusNotFound)
return
}
if rel == "index.json" {
serveMetadata(w, r)
return
}
parts := strings.SplitN(rel, "/", 2)
if len(parts) != 2 {
w.WriteHeader(http.StatusNotFound)
return
}
clean := path.Clean("/" + parts[1])
if clean == "/" {
w.WriteHeader(http.StatusNotFound)
return
}
clean = strings.TrimPrefix(clean, "/")
if strings.HasPrefix(clean, "../") {
w.WriteHeader(http.StatusNotFound)
return
}
objectPath := renderplugin.ObjectPath(parts[0], clean)
obj, err := renderplugin.Storage().Open(objectPath)
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
} else {
log.Error("Unable to open render plugin asset %s: %v", objectPath, err)
w.WriteHeader(http.StatusInternalServerError)
}
return
}
defer obj.Close()
info, err := obj.Stat()
if err != nil {
log.Error("Unable to stat render plugin asset %s: %v", objectPath, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
http.ServeContent(w, r, path.Base(clean), info.ModTime(), obj)
})
}
func serveMetadata(w http.ResponseWriter, r *http.Request) {
meta, err := plugin_service.BuildMetadata(r.Context())
if err != nil {
log.Error("Unable to build render plugin metadata: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if err := json.NewEncoder(w).Encode(meta); err != nil {
log.Error("Failed to encode render plugin metadata: %v", err)
}
}

View File

@ -5,6 +5,7 @@ package repo
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"io"
@ -228,6 +229,14 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText()
ctx.Data["IsExecutable"] = entry.IsExecutable()
ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage()
ctx.Data["RenderFileMimeType"] = fInfo.st.GetMimeType()
if len(buf) > 0 {
chunk := buf
if len(chunk) > typesniffer.SniffContentSize {
chunk = chunk[:typesniffer.SniffContentSize]
}
ctx.Data["RenderFileHeadChunk"] = base64.StdEncoding.EncodeToString(chunk)
}
attrs, ok := prepareFileViewLfsAttrs(ctx)
if !ok {

View File

@ -34,6 +34,7 @@ import (
"code.gitea.io/gitea/routers/web/misc"
"code.gitea.io/gitea/routers/web/org"
org_setting "code.gitea.io/gitea/routers/web/org/setting"
"code.gitea.io/gitea/routers/web/renderplugin"
"code.gitea.io/gitea/routers/web/repo"
"code.gitea.io/gitea/routers/web/repo/actions"
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
@ -232,6 +233,7 @@ func Routes() *web.Router {
routes := web.NewRouter()
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD, OPTIONS", "/assets/render-plugins/*", optionsCorsHandler(), renderplugin.AssetsHandler())
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
@ -772,6 +774,16 @@ func registerWebRoutes(m *web.Router) {
m.Post("/cleanup", admin.CleanupExpiredData)
}, packagesEnabled)
m.Group("/render-plugins", func() {
m.Get("", admin.RenderPlugins)
m.Get("/{id}", admin.RenderPluginDetail)
m.Post("/upload", admin.RenderPluginsUpload)
m.Post("/{id}/enable", admin.RenderPluginsEnable)
m.Post("/{id}/disable", admin.RenderPluginsDisable)
m.Post("/{id}/delete", admin.RenderPluginsDelete)
m.Post("/{id}/upgrade", admin.RenderPluginsUpgrade)
})
m.Group("/hooks", func() {
m.Get("", admin.DefaultOrSystemWebhooks)
m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)

View File

@ -0,0 +1,283 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package renderplugin
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
render_model "code.gitea.io/gitea/models/render"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/renderplugin"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
)
var errManifestNotFound = errors.New("manifest.json not found in plugin archive")
// InstallFromArchive installs or upgrades a plugin from an uploaded ZIP archive.
// If expectedIdentifier is non-empty the archive must contain the matching plugin id.
func InstallFromArchive(ctx context.Context, upload io.Reader, filename, expectedIdentifier string) (*render_model.Plugin, error) {
tmpFile, cleanupFile, err := setting.AppDataTempDir("render-plugins").CreateTempFileRandom("upload", "*.zip")
if err != nil {
return nil, err
}
defer cleanupFile()
if _, err := io.Copy(tmpFile, upload); err != nil {
return nil, err
}
if err := tmpFile.Close(); err != nil {
return nil, err
}
pluginDir, manifest, cleanupDir, err := extractArchive(tmpFile.Name())
if err != nil {
return nil, err
}
defer cleanupDir()
if expectedIdentifier != "" && manifest.ID != expectedIdentifier {
return nil, fmt.Errorf("uploaded plugin id %s does not match %s", manifest.ID, expectedIdentifier)
}
entryPath := filepath.Join(pluginDir, filepath.FromSlash(manifest.Entry))
if ok, _ := util.IsExist(entryPath); !ok {
return nil, fmt.Errorf("plugin entry %s not found", manifest.Entry)
}
if err := replacePluginFiles(manifest.ID, pluginDir); err != nil {
return nil, err
}
plug := &render_model.Plugin{
Identifier: manifest.ID,
Name: manifest.Name,
Version: manifest.Version,
Description: manifest.Description,
Source: strings.TrimSpace(filename),
Entry: manifest.Entry,
FilePatterns: manifest.FilePatterns,
FormatVersion: manifest.SchemaVersion,
}
if err := render_model.UpsertPlugin(ctx, plug); err != nil {
return nil, err
}
return plug, nil
}
// Delete removes a plugin from disk and database.
func Delete(ctx context.Context, plug *render_model.Plugin) error {
if err := deletePluginFiles(plug.Identifier); err != nil {
return err
}
return render_model.DeletePlugin(ctx, plug)
}
// SetEnabled toggles plugin availability after verifying assets exist when enabling.
func SetEnabled(ctx context.Context, plug *render_model.Plugin, enabled bool) error {
if enabled {
if err := ensureEntryExists(plug); err != nil {
return err
}
}
return render_model.SetPluginEnabled(ctx, plug, enabled)
}
// BuildMetadata returns metadata for all enabled plugins.
func BuildMetadata(ctx context.Context) ([]renderplugin.Metadata, error) {
plugs, err := render_model.ListEnabledPlugins(ctx)
if err != nil {
return nil, err
}
base := setting.AppSubURL + "/assets/render-plugins/"
metas := make([]renderplugin.Metadata, 0, len(plugs))
for _, plug := range plugs {
if plug.FormatVersion != renderplugin.SupportedManifestVersion {
log.Warn("Render plugin %s disabled due to incompatible schema version %d", plug.Identifier, plug.FormatVersion)
continue
}
if err := ensureEntryExists(plug); err != nil {
log.Error("Render plugin %s entry missing: %v", plug.Identifier, err)
continue
}
assetsBase := base + plug.Identifier + "/"
metas = append(metas, renderplugin.Metadata{
ID: plug.Identifier,
Name: plug.Name,
Version: plug.Version,
Description: plug.Description,
Entry: plug.Entry,
EntryURL: assetsBase + plug.Entry,
AssetsBase: assetsBase,
FilePatterns: append([]string(nil), plug.FilePatterns...),
SchemaVersion: plug.FormatVersion,
})
}
return metas, nil
}
func ensureEntryExists(plug *render_model.Plugin) error {
entryPath := renderplugin.ObjectPath(plug.Identifier, filepath.ToSlash(plug.Entry))
if _, err := renderplugin.Storage().Stat(entryPath); err != nil {
return fmt.Errorf("plugin entry %s missing: %w", plug.Entry, err)
}
return nil
}
func extractArchive(zipPath string) (string, *renderplugin.Manifest, func(), error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return "", nil, nil, err
}
extractDir, cleanup, err := setting.AppDataTempDir("render-plugins").MkdirTempRandom("extract", "*")
if err != nil {
_ = reader.Close()
return "", nil, nil, err
}
closeAll := func() {
_ = reader.Close()
cleanup()
}
for _, file := range reader.File {
if err := extractZipEntry(file, extractDir); err != nil {
closeAll()
return "", nil, nil, err
}
}
manifestPath, err := findManifest(extractDir)
if err != nil {
closeAll()
return "", nil, nil, err
}
manifestDir := filepath.Dir(manifestPath)
manifest, err := renderplugin.LoadManifest(manifestDir)
if err != nil {
closeAll()
return "", nil, nil, err
}
return manifestDir, manifest, closeAll, nil
}
func extractZipEntry(file *zip.File, dest string) error {
cleanRel := util.PathJoinRelX(file.Name)
if cleanRel == "" || cleanRel == "." {
return nil
}
target := filepath.Join(dest, filepath.FromSlash(cleanRel))
rel, err := filepath.Rel(dest, target)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("archive path %q escapes extraction directory", file.Name)
}
if file.FileInfo().IsDir() {
return os.MkdirAll(target, os.ModePerm)
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks are not supported inside plugin archives: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(target), os.ModePerm); err != nil {
return err
}
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return err
}
return nil
}
func findManifest(root string) (string, error) {
var manifestPath string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.EqualFold(d.Name(), "manifest.json") {
if manifestPath != "" {
return fmt.Errorf("multiple manifest.json files found")
}
manifestPath = path
}
return nil
})
if err != nil {
return "", err
}
if manifestPath == "" {
return "", errManifestNotFound
}
return manifestPath, nil
}
func replacePluginFiles(identifier, srcDir string) error {
if err := deletePluginFiles(identifier); err != nil {
return err
}
return uploadPluginDir(identifier, srcDir)
}
func deletePluginFiles(identifier string) error {
store := renderplugin.Storage()
prefix := renderplugin.ObjectPrefix(identifier)
return store.IterateObjects(prefix, func(path string, obj storage.Object) error {
_ = obj.Close()
return store.Delete(path)
})
}
func uploadPluginDir(identifier, src string) error {
store := renderplugin.Storage()
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks are not supported inside plugin archives")
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
info, err := file.Stat()
if err != nil {
file.Close()
return err
}
objectPath := renderplugin.ObjectPath(identifier, filepath.ToSlash(rel))
_, err = store.Save(objectPath, file, info.Size())
closeErr := file.Close()
if err != nil {
return err
}
return closeErr
})
}

View File

@ -38,6 +38,9 @@
{{ctx.Locale.Tr "packages.title"}}
</a>
{{end}}
<a class="{{if .PageIsAdminRenderPlugins}}active {{end}}item" href="{{AppSubUrl}}/-/admin/render-plugins">
{{ctx.Locale.Tr "admin.render_plugins"}}
</a>
<a class="{{if .PageIsAdminRepositories}}active {{end}}item" href="{{AppSubUrl}}/-/admin/repos">
{{ctx.Locale.Tr "admin.repositories"}}
</a>

View File

@ -0,0 +1,112 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugin-detail")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{.Plugin.Name}}
<div class="ui right">
<a class="ui small button" href="{{AppSubUrl}}/-/admin/render-plugins">{{ctx.Locale.Tr "admin.render_plugins.back_to_list"}}</a>
</div>
<div class="sub header tw-text-normal">{{ctx.Locale.Tr "admin.render_plugins.detail_title" .Plugin.Name}}</div>
</h4>
<div class="ui attached segment">
<table class="ui very basic definition table">
<tbody>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.format_version"}}</td>
<td>{{.Plugin.FormatVersion}}</td>
</tr>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.table.version"}}</td>
<td>{{.Plugin.Version}}</td>
</tr>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}</td>
<td>{{.Plugin.Identifier}}</td>
</tr>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.table.status"}}</td>
<td>
{{if .Plugin.Enabled}}
<span class="ui green basic label">{{ctx.Locale.Tr "admin.render_plugins.status.enabled"}}</span>
{{else}}
<span class="ui grey basic label">{{ctx.Locale.Tr "admin.render_plugins.status.disabled"}}</span>
{{end}}
</td>
</tr>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.description"}}</td>
<td>
{{if .Plugin.Description}}
<div class="tw-whitespace-pre-wrap">{{.Plugin.Description}}</div>
{{else}}
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.description_empty"}}</span>
{{end}}
</td>
</tr>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.entry"}}</td>
<td>{{.Plugin.Entry}}</td>
</tr>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.source"}}</td>
<td>
{{if .Plugin.Source}}
{{.Plugin.Source}}
{{else}}
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.none"}}</span>
{{end}}
</td>
</tr>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}</td>
<td>
{{if .Plugin.FilePatterns}}
{{range $i, $pattern := .Plugin.FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}}
{{else}}
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.file_patterns_empty"}}</span>
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}
</h4>
<div class="ui attached segment">
<div class="tw-flex tw-flex-wrap tw-gap-2">
{{if .Plugin.Enabled}}
<form method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/disable">
{{.CsrfTokenHtml}}
<input type="hidden" name="redirect_to" value="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}">
<button class="ui button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.disable"}}</button>
</form>
{{else}}
<form method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/enable">
{{.CsrfTokenHtml}}
<input type="hidden" name="redirect_to" value="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}">
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.enable"}}</button>
</form>
{{end}}
<form method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/delete">
{{.CsrfTokenHtml}}
<button class="ui red button" type="submit" data-confirm="{{ctx.Locale.Tr "admin.render_plugins.delete_confirm" .Plugin.Name}}">{{ctx.Locale.Tr "admin.render_plugins.delete"}}</button>
</form>
</div>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.render_plugins.detail.upgrade"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/upgrade" enctype="multipart/form-data">
{{.CsrfTokenHtml}}
<input type="hidden" name="redirect_to" value="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}">
<div class="field">
<label>{{ctx.Locale.Tr "admin.render_plugins.upload_label"}}</label>
<input type="file" name="plugin" accept=".zip" required>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.upgrade"}}</button>
</form>
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@ -0,0 +1,65 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.render_plugins"}}
<div class="sub header tw-text-normal">{{ctx.Locale.Tr "admin.render_plugins.description"}}</div>
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/render-plugins/upload" enctype="multipart/form-data">
{{.CsrfTokenHtml}}
<div class="field">
<label>{{ctx.Locale.Tr "admin.render_plugins.upload_label"}}</label>
<input type="file" name="plugin" accept=".zip" required>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.install"}}</button>
</form>
<div class="tw-mt-2 tw-text-sm tw-text-secondary">
{{ctx.Locale.Tr "admin.render_plugins.example_hint"}}
</div>
</div>
<div class="ui attached table segment">
<table class="ui very basic striped table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "admin.render_plugins.table.name"}}</th>
<th>{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}</th>
<th>{{ctx.Locale.Tr "admin.render_plugins.table.version"}}</th>
<th>{{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}</th>
<th>{{ctx.Locale.Tr "admin.render_plugins.table.status"}}</th>
<th class="tw-text-right">{{ctx.Locale.Tr "admin.render_plugins.table.actions"}}</th>
</tr>
</thead>
<tbody>
{{range .Plugins}}
<tr>
<td>
<div>{{.Name}}</div>
<div class="text light tw-text-sm">{{.Description}}</div>
</td>
<td>{{.Identifier}}</td>
<td>{{.Version}}</td>
<td class="tw-text-sm">
{{range $i, $pattern := .FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}}
</td>
<td>
{{if .Enabled}}
<span class="ui green basic label">{{ctx.Locale.Tr "admin.render_plugins.status.enabled"}}</span>
{{else}}
<span class="ui grey basic label">{{ctx.Locale.Tr "admin.render_plugins.status.disabled"}}</span>
{{end}}
</td>
<td class="tw-text-right">
<a class="ui mini basic button" href="{{AppSubUrl}}/-/admin/render-plugins/{{.ID}}">{{ctx.Locale.Tr "view"}}</a>
</td>
</tr>
{{else}}
<tr>
<td class="tw-text-center" colspan="6">{{ctx.Locale.Tr "admin.render_plugins.empty"}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@ -1,5 +1,6 @@
<div {{if .ReadmeInList}}id="readme"{{end}} class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content"
data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}">
data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}"
data-mime-type="{{.RenderFileMimeType}}"{{if .RenderFileHeadChunk}} data-head-chunk="{{.RenderFileHeadChunk}}"{{end}}>
{{- if .FileError}}
<div class="ui error message">

View File

@ -1,20 +1,48 @@
import type {FileRenderPlugin} from '../render/plugin.ts';
import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
import {loadDynamicRenderPlugins} from '../render/plugins/dynamic-plugin.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
const plugins: FileRenderPlugin[] = [];
let pluginsInitialized = false;
let pluginsInitPromise: Promise<void> | null = null;
function initPluginsOnce(): void {
if (plugins.length) return;
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
function decodeHeadChunk(value: string | null): Uint8Array | null {
if (!value) return null;
try {
const binary = window.atob(value);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
buffer[i] = binary.charCodeAt(i);
}
return buffer;
} catch (err) {
console.error('Failed to decode render plugin head chunk', err);
return null;
}
}
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
async function initPluginsOnce(): Promise<void> {
if (pluginsInitialized) return;
if (!pluginsInitPromise) {
pluginsInitPromise = (async () => {
if (!pluginsInitialized) {
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
const dynamicPlugins = await loadDynamicRenderPlugins();
plugins.push(...dynamicPlugins);
pluginsInitialized = true;
}
})();
}
await pluginsInitPromise;
}
function findFileRenderPlugin(filename: string, mimeType: string, headChunk: Uint8Array | null): FileRenderPlugin | null {
return plugins.find((plugin) => plugin.canHandle(filename, mimeType, headChunk)) || null;
}
function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
@ -26,17 +54,17 @@ function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLE
// TODO: if there is only one button, hide it?
}
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string, headChunk: Uint8Array | null) {
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
let rendered = false, errorMsg = '';
try {
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk);
if (plugin) {
container.classList.add('is-loading');
container.setAttribute('data-render-name', plugin.name); // not used yet
await plugin.render(container, rawFileLink);
await plugin.render(container, rawFileLink, {mimeType, headChunk});
rendered = true;
}
} catch (e) {
@ -61,16 +89,16 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initPluginsOnce();
await initPluginsOnce();
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 mimeType = elFileView.getAttribute('data-mime-type') || '';
const headChunk = decodeHeadChunk(elFileView.getAttribute('data-head-chunk'));
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk);
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);
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType, headChunk);
});
}

View File

@ -1,10 +1,19 @@
export type FileRenderOptions = {
/** MIME type reported by the backend (may be empty). */
mimeType?: string;
/** First bytes of the file as raw bytes (<= 1 KiB). */
headChunk?: Uint8Array | null;
/** Additional plugin-specific options. */
[key: string]: any;
};
export type FileRenderPlugin = {
// unique plugin name
name: string;
// test if plugin can handle a specified file
canHandle: (filename: string, mimeType: string) => boolean;
canHandle: (filename: string, mimeType: string, headChunk?: Uint8Array | null) => boolean;
// render file content
render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>;
render: (container: HTMLElement, fileUrl: string, options?: FileRenderOptions) => Promise<void>;
};

View File

@ -40,7 +40,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin {
return {
name: '3d-model-viewer',
canHandle(filename: string, _mimeType: string): boolean {
canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null): boolean {
const ext = extname(filename).toLowerCase();
return SUPPORTED_EXTENSIONS.includes(ext);
},

View File

@ -0,0 +1,96 @@
import type {FileRenderPlugin} from '../plugin.ts';
import {globCompile} from '../../utils/glob.ts';
type RemotePluginMeta = {
schemaVersion: number;
id: string;
name: string;
version: string;
description: string;
entryUrl: string;
assetsBaseUrl: string;
filePatterns: string[];
};
type RemotePluginModule = {
render: (container: HTMLElement, fileUrl: string, options?: any) => void | Promise<void>;
};
const moduleCache = new Map<string, Promise<RemotePluginModule>>();
const SUPPORTED_SCHEMA_VERSION = 1;
async function fetchRemoteMetadata(): Promise<RemotePluginMeta[]> {
const base = window.config.appSubUrl || '';
const response = await window.fetch(`${base}/assets/render-plugins/index.json`, {headers: {'Accept': 'application/json'}});
if (!response.ok) {
throw new Error(`Failed to load render plugin metadata (${response.status})`);
}
return response.json() as Promise<RemotePluginMeta[]>;
}
async function loadRemoteModule(meta: RemotePluginMeta): Promise<RemotePluginModule> {
let cached = moduleCache.get(meta.id);
if (!cached) {
cached = (async () => {
try {
const mod = await import(/* webpackIgnore: true */ meta.entryUrl);
const exported = (mod?.default ?? mod) as RemotePluginModule | undefined;
if (!exported || typeof exported.render !== 'function') {
throw new Error(`Plugin ${meta.id} does not export a render() function`);
}
return exported;
} catch (err) {
moduleCache.delete(meta.id);
throw err;
}
})();
moduleCache.set(meta.id, cached);
}
return cached;
}
function createMatcher(patterns: string[]) {
const compiled = patterns.map((pattern) => {
const normalized = pattern.toLowerCase();
try {
return globCompile(normalized);
} catch (err) {
console.error('Failed to compile render plugin glob pattern', pattern, err);
return null;
}
}).filter(Boolean) as ReturnType<typeof globCompile>[];
return (filename: string) => {
const lower = filename.toLowerCase();
return compiled.some((glob) => glob.regexp.test(lower));
};
}
function wrapRemotePlugin(meta: RemotePluginMeta): FileRenderPlugin {
const matcher = createMatcher(meta.filePatterns);
return {
name: meta.name,
canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null) {
return matcher(filename);
},
async render(container, fileUrl, options) {
const remote = await loadRemoteModule(meta);
await remote.render(container, fileUrl, options);
},
};
}
export async function loadDynamicRenderPlugins(): Promise<FileRenderPlugin[]> {
try {
const metadata = await fetchRemoteMetadata();
return metadata.filter((meta) => {
if (meta.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
console.warn(`Render plugin ${meta.id} ignored due to incompatible schemaVersion ${meta.schemaVersion}`);
return false;
}
return true;
}).map((meta) => wrapRemotePlugin(meta));
} catch (err) {
console.error('Failed to load dynamic render plugins', err);
return [];
}
}

View File

@ -4,7 +4,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin {
return {
name: 'pdf-viewer',
canHandle(filename: string, _mimeType: string): boolean {
canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null): boolean {
return filename.toLowerCase().endsWith('.pdf');
},