|
|
|
@@ -0,0 +1,164 @@
|
|
|
|
|
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
|
|
|
|
|
|
|
|
|
|
export type PluginOrder = "pre" | "normal" | "post";
|
|
|
|
|
|
|
|
|
|
export interface PluginContext {
|
|
|
|
|
root: string;
|
|
|
|
|
mode: "development" | "production";
|
|
|
|
|
command: "dev" | "build" | "test";
|
|
|
|
|
profile?: string;
|
|
|
|
|
metadata: Map<string, unknown>;
|
|
|
|
|
warn(message: string): void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TransformContext extends PluginContext {
|
|
|
|
|
file: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WrnexusPlugin {
|
|
|
|
|
name: string;
|
|
|
|
|
version?: string;
|
|
|
|
|
enforce?: PluginOrder;
|
|
|
|
|
/** Plugin names that must execute first. */
|
|
|
|
|
after?: string[];
|
|
|
|
|
/** Plugin names that must execute later. */
|
|
|
|
|
before?: string[];
|
|
|
|
|
configure?(config: Record<string, unknown>, context: PluginContext): void | Promise<void>;
|
|
|
|
|
configResolved?(
|
|
|
|
|
config: Readonly<Record<string, unknown>>,
|
|
|
|
|
context: PluginContext,
|
|
|
|
|
): void | Promise<void>;
|
|
|
|
|
transformAst?(ast: PageAst, context: TransformContext): PageAst | void | Promise<PageAst | void>;
|
|
|
|
|
transformCode?(code: string, context: TransformContext): string | void | Promise<string | void>;
|
|
|
|
|
diagnostics?(ast: PageAst, context: TransformContext): WrnDiagnostic[] | Promise<WrnDiagnostic[]>;
|
|
|
|
|
routes?(routes: unknown[], context: PluginContext): unknown[] | void | Promise<unknown[] | void>;
|
|
|
|
|
configureServer?(server: unknown, context: PluginContext): void | Promise<void>;
|
|
|
|
|
buildStart?(context: PluginContext): void | Promise<void>;
|
|
|
|
|
buildEnd?(result: unknown, context: PluginContext): void | Promise<void>;
|
|
|
|
|
devToolbarPanels?(context: PluginContext): unknown[] | Promise<unknown[]>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type PluginInput = WrnexusPlugin | false | null | undefined | PluginInput[];
|
|
|
|
|
|
|
|
|
|
export function definePlugin(plugin: WrnexusPlugin): WrnexusPlugin {
|
|
|
|
|
if (!plugin.name || !/^[a-z0-9@][a-z0-9@/._-]*$/i.test(plugin.name)) {
|
|
|
|
|
throw new Error("WRN-PLUGIN-NAME: plugins require a stable package-style name.");
|
|
|
|
|
}
|
|
|
|
|
return plugin;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flatten(input: PluginInput, output: WrnexusPlugin[]): void {
|
|
|
|
|
if (!input) return;
|
|
|
|
|
if (Array.isArray(input)) {
|
|
|
|
|
for (const entry of input) flatten(entry, output);
|
|
|
|
|
} else {
|
|
|
|
|
output.push(definePlugin(input));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rank(plugin: WrnexusPlugin): number {
|
|
|
|
|
return plugin.enforce === "pre" ? 0 : plugin.enforce === "post" ? 2 : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Resolve plugin order deterministically and reject duplicates/cycles. */
|
|
|
|
|
export function resolvePlugins(input: PluginInput): WrnexusPlugin[] {
|
|
|
|
|
const plugins: WrnexusPlugin[] = [];
|
|
|
|
|
flatten(input, plugins);
|
|
|
|
|
const byName = new Map<string, WrnexusPlugin>();
|
|
|
|
|
for (const plugin of plugins) {
|
|
|
|
|
if (byName.has(plugin.name)) throw new Error(`WRN-PLUGIN-DUPLICATE: ${plugin.name}`);
|
|
|
|
|
byName.set(plugin.name, plugin);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const edges = new Map<string, Set<string>>();
|
|
|
|
|
for (const plugin of plugins) edges.set(plugin.name, new Set());
|
|
|
|
|
for (const plugin of plugins) {
|
|
|
|
|
for (const dependency of plugin.after ?? []) {
|
|
|
|
|
if (byName.has(dependency)) edges.get(dependency)!.add(plugin.name);
|
|
|
|
|
}
|
|
|
|
|
for (const dependent of plugin.before ?? []) {
|
|
|
|
|
if (byName.has(dependent)) edges.get(plugin.name)!.add(dependent);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const left of plugins) {
|
|
|
|
|
for (const right of plugins) {
|
|
|
|
|
if (rank(left) < rank(right)) edges.get(left.name)!.add(right.name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const indegree = new Map(plugins.map((plugin) => [plugin.name, 0]));
|
|
|
|
|
for (const targets of edges.values()) {
|
|
|
|
|
for (const target of targets) indegree.set(target, (indegree.get(target) ?? 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
const ready = plugins
|
|
|
|
|
.filter((plugin) => indegree.get(plugin.name) === 0)
|
|
|
|
|
.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
|
|
|
|
const resolved: WrnexusPlugin[] = [];
|
|
|
|
|
while (ready.length) {
|
|
|
|
|
const plugin = ready.shift()!;
|
|
|
|
|
resolved.push(plugin);
|
|
|
|
|
for (const target of edges.get(plugin.name) ?? []) {
|
|
|
|
|
indegree.set(target, indegree.get(target)! - 1);
|
|
|
|
|
if (indegree.get(target) === 0) {
|
|
|
|
|
ready.push(byName.get(target)!);
|
|
|
|
|
ready.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (resolved.length !== plugins.length) {
|
|
|
|
|
const cyclic = plugins
|
|
|
|
|
.filter((plugin) => !resolved.includes(plugin))
|
|
|
|
|
.map((plugin) => plugin.name);
|
|
|
|
|
throw new Error(`WRN-PLUGIN-CYCLE: ${cyclic.join(", ")}`);
|
|
|
|
|
}
|
|
|
|
|
return resolved;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PluginRunner {
|
|
|
|
|
readonly plugins: readonly WrnexusPlugin[];
|
|
|
|
|
configure(config: Record<string, unknown>): Promise<void>;
|
|
|
|
|
configResolved(config: Readonly<Record<string, unknown>>): Promise<void>;
|
|
|
|
|
transformAst(ast: PageAst, file: string): Promise<PageAst>;
|
|
|
|
|
transformCode(code: string, file: string): Promise<string>;
|
|
|
|
|
diagnostics(ast: PageAst, file: string): Promise<WrnDiagnostic[]>;
|
|
|
|
|
hook(name: "buildStart" | "buildEnd" | "configureServer", value?: unknown): Promise<void>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner {
|
|
|
|
|
const plugins = resolvePlugins(input);
|
|
|
|
|
const transformContext = (file: string): TransformContext => ({ ...context, file });
|
|
|
|
|
return {
|
|
|
|
|
plugins,
|
|
|
|
|
async configure(config) {
|
|
|
|
|
for (const plugin of plugins) await plugin.configure?.(config, context);
|
|
|
|
|
},
|
|
|
|
|
async configResolved(config) {
|
|
|
|
|
for (const plugin of plugins) await plugin.configResolved?.(config, context);
|
|
|
|
|
},
|
|
|
|
|
async transformAst(ast, file) {
|
|
|
|
|
let current = ast;
|
|
|
|
|
for (const plugin of plugins)
|
|
|
|
|
current = (await plugin.transformAst?.(current, transformContext(file))) ?? current;
|
|
|
|
|
return current;
|
|
|
|
|
},
|
|
|
|
|
async transformCode(code, file) {
|
|
|
|
|
let current = code;
|
|
|
|
|
for (const plugin of plugins)
|
|
|
|
|
current = (await plugin.transformCode?.(current, transformContext(file))) ?? current;
|
|
|
|
|
return current;
|
|
|
|
|
},
|
|
|
|
|
async diagnostics(ast, file) {
|
|
|
|
|
const all: WrnDiagnostic[] = [];
|
|
|
|
|
for (const plugin of plugins)
|
|
|
|
|
all.push(...((await plugin.diagnostics?.(ast, transformContext(file))) ?? []));
|
|
|
|
|
return all;
|
|
|
|
|
},
|
|
|
|
|
async hook(name, value) {
|
|
|
|
|
for (const plugin of plugins) {
|
|
|
|
|
if (name === "buildStart") await plugin.buildStart?.(context);
|
|
|
|
|
else if (name === "buildEnd") await plugin.buildEnd?.(value, context);
|
|
|
|
|
else await plugin.configureServer?.(value, context);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|