328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import type { PageAst, ViewNode, WrnDiagnostic } from "@wrnexus/syntax";
|
|
import { normalizeClientRuntime, normalizePackageAsset, validateStyleIds } from "./manifest.ts";
|
|
import type {
|
|
ClientRuntimeDefinition,
|
|
PackageAssetDefinition,
|
|
PackageStyleDefinition,
|
|
PackageRouteDefinition,
|
|
PackageMigrationDefinition,
|
|
PluginDevToolbarPanel,
|
|
PluginContributions,
|
|
PluginContext,
|
|
PluginInput,
|
|
PluginOrder,
|
|
PluginPermission,
|
|
PluginRunner,
|
|
PluginDirective,
|
|
PluginCliCommand,
|
|
PluginVirtualModule,
|
|
PluginDeploymentAdapter,
|
|
PluginConfigSchema,
|
|
TransformContext,
|
|
WrnexusPlugin,
|
|
} from "./types.ts";
|
|
|
|
export * from "./types.ts";
|
|
export * from "./manifest.ts";
|
|
export { discoverPlugins, type DiscoverPluginOptions } from "./discovery.ts";
|
|
export { testPluginCompatibility } from "./compatibility.ts";
|
|
export type { PluginCompatibilityResult, PluginCompatibilityTarget } from "./compatibility.ts";
|
|
|
|
export { definePlugin, flattenPlugins } from "./base.ts";
|
|
import { flattenPlugins } from "./base.ts";
|
|
|
|
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 = flattenPlugins(input);
|
|
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;
|
|
}
|
|
|
|
async function resolveContribution<T>(
|
|
value: T[] | ((context: PluginContext) => T[] | Promise<T[]>) | undefined,
|
|
context: PluginContext,
|
|
): Promise<T[]> {
|
|
if (!value) return [];
|
|
return typeof value === "function"
|
|
? await (value as (context: PluginContext) => T[] | Promise<T[]>)(context)
|
|
: value;
|
|
}
|
|
|
|
function styleRank(style: PackageStyleDefinition): number {
|
|
return style.order === "pre" ? 0 : style.order === "post" ? 2 : 1;
|
|
}
|
|
|
|
function sortStyles(styles: readonly PackageStyleDefinition[]): PackageStyleDefinition[] {
|
|
return styles
|
|
.map((style, index) => ({ style, index }))
|
|
.sort(
|
|
(left, right) => styleRank(left.style) - styleRank(right.style) || left.index - right.index,
|
|
)
|
|
.map(({ style }) => style);
|
|
}
|
|
|
|
function assertUnique<T>(kind: string, entries: readonly T[], key: (entry: T) => string): void {
|
|
const seen = new Set<string>();
|
|
for (const entry of entries) {
|
|
const value = key(entry);
|
|
if (seen.has(value)) throw new Error(`WRN-PLUGIN-${kind}-DUPLICATE: ${value}`);
|
|
seen.add(value);
|
|
}
|
|
}
|
|
|
|
async function transformDirectives(
|
|
nodes: ViewNode[],
|
|
directives: readonly PluginDirective[],
|
|
context: TransformContext,
|
|
): Promise<void> {
|
|
const registry = new Map(directives.map((directive) => [directive.name, directive]));
|
|
for (const node of nodes) {
|
|
if (node.type === "element") {
|
|
for (const attribute of node.attrs) {
|
|
if (!attribute.name.startsWith("use:")) continue;
|
|
const name = attribute.name.slice(4);
|
|
const directive = registry.get(name);
|
|
if (!directive) continue;
|
|
const transformed = await directive.transform?.(attribute.value, context);
|
|
if (transformed) {
|
|
attribute.name = transformed.name;
|
|
attribute.value = transformed.value;
|
|
} else attribute.name = `data-wrn-directive-${name}`;
|
|
}
|
|
await transformDirectives(node.children, directives, context);
|
|
} else if (node.type === "each") {
|
|
await transformDirectives(node.body, directives, context);
|
|
await transformDirectives(node.empty, directives, context);
|
|
} else if (node.type === "if") {
|
|
for (const branch of node.branches)
|
|
await transformDirectives(branch.body, directives, context);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner {
|
|
const plugins = resolvePlugins(input);
|
|
let contributionCache: PluginContributions | null = null;
|
|
let setupComplete = false;
|
|
const transformContext = (file: string): TransformContext => ({ ...context, file });
|
|
|
|
const contributions = async (): Promise<PluginContributions> => {
|
|
if (contributionCache) return contributionCache;
|
|
const componentDirs: string[] = [];
|
|
const clientRuntimes: ClientRuntimeDefinition[] = [];
|
|
const assets: PackageAssetDefinition[] = [];
|
|
const styles: PackageStyleDefinition[] = [];
|
|
const routes: PackageRouteDefinition[] = [];
|
|
const middleware: string[] = [];
|
|
const migrations: PackageMigrationDefinition[] = [];
|
|
const directives: PluginDirective[] = [];
|
|
const cliCommands: PluginCliCommand[] = [];
|
|
const virtualModules: PluginVirtualModule[] = [];
|
|
const deploymentAdapters: PluginDeploymentAdapter[] = [];
|
|
const configSchemas: PluginConfigSchema[] = [];
|
|
const documentation: string[] = [];
|
|
const typeDefinitions: string[] = [];
|
|
|
|
for (const plugin of plugins) {
|
|
componentDirs.push(...(await resolveContribution(plugin.componentDirs, context)));
|
|
clientRuntimes.push(...(await resolveContribution(plugin.clientRuntimes, context)));
|
|
assets.push(...(await resolveContribution(plugin.assets, context)));
|
|
styles.push(...(await resolveContribution(plugin.styleSources, context)));
|
|
routes.push(...(await resolveContribution(plugin.routeEntries, context)));
|
|
middleware.push(...(await resolveContribution(plugin.middleware, context)));
|
|
migrations.push(...(await resolveContribution(plugin.migrations, context)));
|
|
directives.push(...(await resolveContribution(plugin.directives, context)));
|
|
cliCommands.push(...(await resolveContribution(plugin.cliCommands, context)));
|
|
virtualModules.push(...(await resolveContribution(plugin.virtualModules, context)));
|
|
deploymentAdapters.push(...(await resolveContribution(plugin.deploymentAdapters, context)));
|
|
configSchemas.push(...(await resolveContribution(plugin.configSchemas, context)));
|
|
documentation.push(...(await resolveContribution(plugin.documentation, context)));
|
|
typeDefinitions.push(...(await resolveContribution(plugin.typeDefinitions, context)));
|
|
}
|
|
|
|
const normalizedRuntimes = clientRuntimes.map(normalizeClientRuntime);
|
|
const normalizedAssets = assets.map(normalizePackageAsset);
|
|
validateStyleIds(styles);
|
|
assertUnique("RUNTIME", normalizedRuntimes, (entry) => entry.id);
|
|
assertUnique("RUNTIME-PATH", normalizedRuntimes, (entry) => entry.publicPath!);
|
|
assertUnique("ASSET", normalizedAssets, (entry) => entry.id);
|
|
assertUnique("ASSET-PATH", normalizedAssets, (entry) => entry.publicPath!);
|
|
assertUnique(
|
|
"PUBLIC-PATH",
|
|
[...normalizedRuntimes, ...normalizedAssets],
|
|
(entry) => entry.publicPath!,
|
|
);
|
|
for (const route of routes) {
|
|
if (!route.path.startsWith("/") || route.path.includes("..")) {
|
|
throw new Error(`WRN-PLUGIN-ROUTE-PATH: unsafe ${route.kind} route '${route.path}'.`);
|
|
}
|
|
if (!route.entry) throw new Error(`WRN-PLUGIN-ROUTE-ENTRY: '${route.path}' has no entry.`);
|
|
}
|
|
assertUnique("ROUTE", routes, (entry) => `${entry.kind}:${entry.path}`);
|
|
for (const migration of migrations) {
|
|
if (!migration.id.trim())
|
|
throw new Error("WRN-PLUGIN-MIGRATION-ID: migration id is required.");
|
|
if (!migration.entry && migration.source === undefined) {
|
|
throw new Error(`WRN-PLUGIN-MIGRATION-SOURCE: ${migration.id} needs entry or source.`);
|
|
}
|
|
}
|
|
assertUnique("MIGRATION", migrations, (entry) => `${entry.database ?? "default"}:${entry.id}`);
|
|
assertUnique("DIRECTIVE", directives, (entry) => entry.name);
|
|
assertUnique("CLI-COMMAND", cliCommands, (entry) => entry.name);
|
|
assertUnique("VIRTUAL-MODULE", virtualModules, (entry) => entry.id);
|
|
assertUnique("DEPLOYMENT-ADAPTER", deploymentAdapters, (entry) => entry.name);
|
|
assertUnique("CONFIG-SCHEMA", configSchemas, (entry) => entry.namespace);
|
|
|
|
contributionCache = {
|
|
componentDirs: [...new Set(componentDirs)],
|
|
clientRuntimes: normalizedRuntimes,
|
|
assets: normalizedAssets,
|
|
styles: sortStyles(styles),
|
|
routes,
|
|
middleware: [...new Set(middleware)],
|
|
migrations,
|
|
directives,
|
|
cliCommands,
|
|
virtualModules,
|
|
deploymentAdapters,
|
|
configSchemas,
|
|
documentation: [...new Set(documentation)],
|
|
typeDefinitions: [...new Set(typeDefinitions)],
|
|
};
|
|
context.metadata.set("@wrnexus/plugin:contributions", contributionCache);
|
|
return contributionCache;
|
|
};
|
|
|
|
return {
|
|
plugins,
|
|
async configure(config) {
|
|
if (!setupComplete) {
|
|
for (const plugin of plugins) await plugin.setup?.(context);
|
|
setupComplete = true;
|
|
}
|
|
for (const plugin of plugins) await plugin.configure?.(config, context);
|
|
contributionCache = null;
|
|
},
|
|
async configResolved(config) {
|
|
for (const plugin of plugins) await plugin.configResolved?.(config, context);
|
|
contributionCache = null;
|
|
const resolved = await contributions();
|
|
for (const schema of resolved.configSchemas)
|
|
await schema.validate(config[schema.namespace], context);
|
|
},
|
|
async transformAst(ast, file) {
|
|
let current = ast;
|
|
for (const plugin of plugins)
|
|
current = (await plugin.transformAst?.(current, transformContext(file))) ?? current;
|
|
await transformDirectives(
|
|
current.view,
|
|
(await contributions()).directives,
|
|
transformContext(file),
|
|
);
|
|
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;
|
|
},
|
|
contributions,
|
|
async devToolbarPanels() {
|
|
const panels: PluginDevToolbarPanel[] = [];
|
|
for (const plugin of plugins)
|
|
panels.push(...((await plugin.devToolbarPanels?.(context)) ?? []));
|
|
return panels;
|
|
},
|
|
async transformRoutes(routes) {
|
|
let current = routes;
|
|
for (const plugin of plugins) current = (await plugin.routes?.(current, context)) ?? current;
|
|
return current;
|
|
},
|
|
async render(html) {
|
|
let current = html;
|
|
for (const plugin of plugins) current = (await plugin.render?.(current, context)) ?? current;
|
|
return current;
|
|
},
|
|
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 if (name === "configureServer") await plugin.configureServer?.(value, context);
|
|
else if (name === "deploy") await plugin.deploy?.(value, context);
|
|
else if (name === "shutdown") await plugin.shutdown?.(context);
|
|
else await plugin.hmrUpdate?.((value as readonly string[]) ?? [], context);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// Preserve direct type imports used by older applications.
|
|
export type {
|
|
PageAst,
|
|
WrnDiagnostic,
|
|
PluginOrder,
|
|
PluginContext,
|
|
TransformContext,
|
|
WrnexusPlugin,
|
|
PluginPermission,
|
|
PluginInput,
|
|
PluginRunner,
|
|
};
|