Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+38
View File
@@ -0,0 +1,38 @@
// Per-plugin view resolver: render plugins/<id>/views/<view>.ejs and let a plugin view
// reuse the core building-block partials. EJS resolves an include() relative to the current file
// first, then against the `views` roots — so passing [plugin views, core views] makes both the
// plugin's own partials/subfolders and every core partial reachable (the plugin root first, so a
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
import { isAbsolute, join, relative } from "node:path";
import * as ejs from "ejs";
const CONTROL_CHARS = /[\x00-\x1f]/;
// Resolve a view name → absolute .ejs path within plugins/<id>/views, or null if it escapes that
// dir (traversal) or carries a control char. Names may be nested ("shifts/edit"); a missing
// extension defaults to .ejs.
export function resolveViewPath(pluginsDir: string, pluginId: string, view: string): string | null {
if (CONTROL_CHARS.test(view)) return null;
const viewsDir = join(pluginsDir, pluginId, "views");
const file = join(viewsDir, view.endsWith(".ejs") ? view : `${view}.ejs`);
const rel = relative(viewsDir, file);
return rel.startsWith("..") || isAbsolute(rel) ? null : file;
}
export interface PluginViewOptions {
cache: boolean;
coreViewsDir: string; // core views/ root — its partials become include() roots for plugin views
pluginsDir: string;
}
// Bind the dirs/cache once; the returned fn renders a named view for a given plugin id. Rejects on
// an out-of-bounds view name (developer error — fail loud, like the rest of the host).
export function renderPluginView(options: PluginViewOptions) {
return async (pluginId: string, view: string, data: Record<string, unknown>): Promise<string> => {
const file = resolveViewPath(options.pluginsDir, pluginId, view);
if (file === null) throw new Error(`invalid view name "${view}" for plugin "${pluginId}"`);
const views = [join(options.pluginsDir, pluginId, "views"), options.coreViewsDir];
return ejs.renderFile(file, data, { cache: options.cache, views });
};
}