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(); 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>(); 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( value: T[] | ((context: PluginContext) => T[] | Promise) | undefined, context: PluginContext, ): Promise { if (!value) return []; return typeof value === "function" ? await (value as (context: PluginContext) => T[] | Promise)(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(kind: string, entries: readonly T[], key: (entry: T) => string): void { const seen = new Set(); 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 { 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 => { 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, };