Files
WRNexusJS/packages/plugin/src/index.ts
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

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