release: WRNexusJS 0.4.0
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./manifest": "./src/manifest.ts",
|
||||
"./discovery": "./src/discovery.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PluginInput, WrnexusPlugin } from "./types.ts";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function flattenPlugins(input: PluginInput, output: WrnexusPlugin[] = []): WrnexusPlugin[] {
|
||||
if (!input) return output;
|
||||
if (Array.isArray(input)) {
|
||||
for (const entry of input) flattenPlugins(entry, output);
|
||||
} else {
|
||||
output.push(definePlugin(input));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type {
|
||||
PackagePluginManifest,
|
||||
PluginInput,
|
||||
WrnexusPackageManifest,
|
||||
WrnexusPlugin,
|
||||
ClientRuntimeDefinition,
|
||||
PackageAssetDefinition,
|
||||
PackageStyleDefinition,
|
||||
PackageRouteDefinition,
|
||||
PackageMigrationDefinition,
|
||||
} from "./types.ts";
|
||||
import { definePlugin, flattenPlugins } from "./base.ts";
|
||||
|
||||
interface PackageJson {
|
||||
name?: string;
|
||||
version?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
workspaces?: string[] | { packages?: string[] };
|
||||
wrnexus?: WrnexusPackageManifest;
|
||||
}
|
||||
|
||||
export interface DiscoverPluginOptions {
|
||||
includeDevDependencies?: boolean;
|
||||
/** Fail package discovery immediately instead of degrading to a warning. */
|
||||
strict?: boolean;
|
||||
includePeerDependencies?: boolean;
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
function readJson(path: string): PackageJson | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as PackageJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function nearestWorkspaceRoot(start: string): string | null {
|
||||
let current = resolve(start);
|
||||
while (true) {
|
||||
const packageJson = readJson(join(current, "package.json"));
|
||||
if (packageJson?.workspaces) return current;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function workspacePatterns(pkg: PackageJson): string[] {
|
||||
if (Array.isArray(pkg.workspaces)) return pkg.workspaces;
|
||||
return pkg.workspaces?.packages ?? [];
|
||||
}
|
||||
|
||||
function expandSimpleWorkspacePattern(root: string, pattern: string): string[] {
|
||||
const normalized = pattern.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
if (!normalized.includes("*")) return [join(root, normalized)];
|
||||
const star = normalized.indexOf("*");
|
||||
const before = normalized.slice(0, star).replace(/\/$/, "");
|
||||
const after = normalized.slice(star + 1).replace(/^\//, "");
|
||||
const base = join(root, before);
|
||||
if (!existsSync(base)) return [];
|
||||
return readdirSync(base)
|
||||
.map((name) => join(base, name, after))
|
||||
.filter((path) => existsSync(path) && statSync(path).isDirectory());
|
||||
}
|
||||
|
||||
function workspacePackageRoots(root: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const rootPackage = readJson(join(root, "package.json"));
|
||||
if (!rootPackage) return result;
|
||||
for (const pattern of workspacePatterns(rootPackage)) {
|
||||
for (const candidate of expandSimpleWorkspacePattern(root, pattern)) {
|
||||
const pkg = readJson(join(candidate, "package.json"));
|
||||
if (pkg?.name) result.set(pkg.name, candidate);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolvePackageRoot(
|
||||
appRoot: string,
|
||||
name: string,
|
||||
workspaces: Map<string, string>,
|
||||
): string | null {
|
||||
const workspace = workspaces.get(name);
|
||||
if (workspace) return workspace;
|
||||
const require = createRequire(join(appRoot, "package.json"));
|
||||
try {
|
||||
return dirname(require.resolve(`${name}/package.json`));
|
||||
} catch {
|
||||
try {
|
||||
const entry = require.resolve(name);
|
||||
let current = dirname(entry);
|
||||
while (true) {
|
||||
const pkg = readJson(join(current, "package.json"));
|
||||
if (pkg?.name === name) return current;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntime(
|
||||
packageRoot: string,
|
||||
value: ClientRuntimeDefinition,
|
||||
): ClientRuntimeDefinition {
|
||||
return {
|
||||
...value,
|
||||
entry: value.entry ? resolve(packageRoot, value.entry) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAsset(packageRoot: string, value: PackageAssetDefinition): PackageAssetDefinition {
|
||||
return {
|
||||
...value,
|
||||
entry: value.entry ? resolve(packageRoot, value.entry) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStyle(packageRoot: string, value: PackageStyleDefinition): PackageStyleDefinition {
|
||||
return {
|
||||
...value,
|
||||
entry: value.entry ? resolve(packageRoot, value.entry) : undefined,
|
||||
source: value.source ? resolve(packageRoot, value.source) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRoute(packageRoot: string, value: PackageRouteDefinition): PackageRouteDefinition {
|
||||
return { ...value, entry: resolve(packageRoot, value.entry) };
|
||||
}
|
||||
|
||||
function resolveMigration(
|
||||
packageRoot: string,
|
||||
value: PackageMigrationDefinition,
|
||||
): PackageMigrationDefinition {
|
||||
return { ...value, entry: value.entry ? resolve(packageRoot, value.entry) : undefined };
|
||||
}
|
||||
|
||||
function manifestContributionPlugin(
|
||||
packageRoot: string,
|
||||
packageName: string,
|
||||
packageVersion: string | undefined,
|
||||
manifest: WrnexusPackageManifest,
|
||||
): WrnexusPlugin | null {
|
||||
const components = (manifest.components ?? []).map((value) => resolve(packageRoot, value));
|
||||
const runtimes = (manifest.clientRuntimes ?? []).map((value) =>
|
||||
resolveRuntime(packageRoot, value),
|
||||
);
|
||||
const assets = (manifest.assets ?? []).map((value) => resolveAsset(packageRoot, value));
|
||||
const styles = (manifest.styles ?? []).map((value) => resolveStyle(packageRoot, value));
|
||||
const routes = (manifest.routes ?? []).map((value) => resolveRoute(packageRoot, value));
|
||||
const middleware = (manifest.middleware ?? []).map((value) => resolve(packageRoot, value));
|
||||
const migrations = (manifest.migrations ?? []).map((value) =>
|
||||
resolveMigration(packageRoot, value),
|
||||
);
|
||||
if (
|
||||
!components.length &&
|
||||
!runtimes.length &&
|
||||
!assets.length &&
|
||||
!styles.length &&
|
||||
!routes.length &&
|
||||
!middleware.length &&
|
||||
!migrations.length
|
||||
)
|
||||
return null;
|
||||
return definePlugin({
|
||||
name: `${packageName}/manifest`,
|
||||
version: manifest.version ?? packageVersion,
|
||||
componentDirs: components,
|
||||
clientRuntimes: runtimes,
|
||||
assets,
|
||||
styleSources: styles,
|
||||
routeEntries: routes,
|
||||
middleware,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
function pluginManifest(value: string | PackagePluginManifest): PackagePluginManifest {
|
||||
return typeof value === "string" ? { plugin: value } : value;
|
||||
}
|
||||
|
||||
async function loadPlugin(
|
||||
packageRoot: string,
|
||||
manifest: PackagePluginManifest,
|
||||
): Promise<WrnexusPlugin> {
|
||||
const modulePath = resolve(packageRoot, manifest.plugin);
|
||||
const mod = (await import(pathToFileURL(modulePath).href)) as Record<string, unknown>;
|
||||
const exportName = manifest.export;
|
||||
let candidate = exportName ? mod[exportName] : (mod.default ?? mod.plugin);
|
||||
if (candidate === undefined) {
|
||||
throw new Error(`WRN-PLUGIN-DISCOVERY-EXPORT: ${modulePath} has no plugin export.`);
|
||||
}
|
||||
const shouldInvoke =
|
||||
manifest.factory === true || (manifest.factory !== false && typeof candidate === "function");
|
||||
if (shouldInvoke) candidate = await (candidate as () => unknown)();
|
||||
return definePlugin(candidate as WrnexusPlugin);
|
||||
}
|
||||
|
||||
function dependencyNames(pkg: PackageJson, options: DiscoverPluginOptions): string[] {
|
||||
const all = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.optionalDependencies ?? {}),
|
||||
...(options.includeDevDependencies === false ? {} : (pkg.devDependencies ?? {})),
|
||||
...(options.includePeerDependencies ? (pkg.peerDependencies ?? {}) : {}),
|
||||
};
|
||||
return Object.keys(all).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover package plugins declared through package.json `wrnexus.plugin`.
|
||||
* Explicit plugins win; automatic duplicates are skipped instead of failing.
|
||||
*/
|
||||
export async function discoverPlugins(
|
||||
appRoot: string,
|
||||
explicit: PluginInput,
|
||||
options: DiscoverPluginOptions = {},
|
||||
): Promise<PluginInput> {
|
||||
const appPackage = readJson(join(appRoot, "package.json"));
|
||||
if (!appPackage) return explicit;
|
||||
|
||||
const explicitPlugins = flattenPlugins(explicit);
|
||||
const explicitNames = new Set(explicitPlugins.map((plugin) => plugin.name));
|
||||
const workspaceRoot = nearestWorkspaceRoot(appRoot);
|
||||
const workspaces = workspaceRoot
|
||||
? workspacePackageRoots(workspaceRoot)
|
||||
: new Map<string, string>();
|
||||
const automatic: WrnexusPlugin[] = [];
|
||||
|
||||
for (const name of dependencyNames(appPackage, options)) {
|
||||
const packageRoot = resolvePackageRoot(appRoot, name, workspaces);
|
||||
if (!packageRoot) continue;
|
||||
const packageJson = readJson(join(packageRoot, "package.json"));
|
||||
const packageManifest = packageJson?.wrnexus;
|
||||
if (!packageManifest) continue;
|
||||
const manifestPlugin = manifestContributionPlugin(
|
||||
packageRoot,
|
||||
packageJson?.name ?? name,
|
||||
packageJson?.version,
|
||||
packageManifest,
|
||||
);
|
||||
if (manifestPlugin && !explicitNames.has(manifestPlugin.name)) automatic.push(manifestPlugin);
|
||||
|
||||
const declared = packageManifest.plugin;
|
||||
if (!declared) continue;
|
||||
const manifest = pluginManifest(declared);
|
||||
if (manifest.autoDiscover === false) continue;
|
||||
try {
|
||||
const plugin = await loadPlugin(packageRoot, manifest);
|
||||
if (!explicitNames.has(plugin.name) && !automatic.some((item) => item.name === plugin.name)) {
|
||||
automatic.push(plugin);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `Failed to auto-load ${name}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
if (options.strict) throw new Error(`WRN-PLUGIN-DISCOVERY: ${message}`, { cause: error });
|
||||
options.warn?.(message);
|
||||
}
|
||||
}
|
||||
|
||||
return [automatic, explicitPlugins];
|
||||
}
|
||||
+142
-64
@@ -1,60 +1,27 @@
|
||||
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
|
||||
import { normalizeClientRuntime, normalizePackageAsset, validateStyleIds } from "./manifest.ts";
|
||||
import type {
|
||||
ClientRuntimeDefinition,
|
||||
PackageAssetDefinition,
|
||||
PackageStyleDefinition,
|
||||
PackageRouteDefinition,
|
||||
PackageMigrationDefinition,
|
||||
PluginDevToolbarPanel,
|
||||
PluginContributions,
|
||||
PluginContext,
|
||||
PluginInput,
|
||||
PluginOrder,
|
||||
PluginRunner,
|
||||
TransformContext,
|
||||
WrnexusPlugin,
|
||||
} from "./types.ts";
|
||||
|
||||
export type PluginOrder = "pre" | "normal" | "post";
|
||||
export * from "./types.ts";
|
||||
export * from "./manifest.ts";
|
||||
export { discoverPlugins, type DiscoverPluginOptions } from "./discovery.ts";
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -62,8 +29,7 @@ function rank(plugin: WrnexusPlugin): number {
|
||||
|
||||
/** Resolve plugin order deterministically and reject duplicates/cycles. */
|
||||
export function resolvePlugins(input: PluginInput): WrnexusPlugin[] {
|
||||
const plugins: WrnexusPlugin[] = [];
|
||||
flatten(input, plugins);
|
||||
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}`);
|
||||
@@ -114,26 +80,114 @@ export function resolvePlugins(input: PluginInput): WrnexusPlugin[] {
|
||||
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>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner {
|
||||
const plugins = resolvePlugins(input);
|
||||
let contributionCache: PluginContributions | null = null;
|
||||
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[] = [];
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
contributionCache = {
|
||||
componentDirs: [...new Set(componentDirs)],
|
||||
clientRuntimes: normalizedRuntimes,
|
||||
assets: normalizedAssets,
|
||||
styles: sortStyles(styles),
|
||||
routes,
|
||||
middleware: [...new Set(middleware)],
|
||||
migrations,
|
||||
};
|
||||
context.metadata.set("@wrnexus/plugin:contributions", contributionCache);
|
||||
return contributionCache;
|
||||
};
|
||||
|
||||
return {
|
||||
plugins,
|
||||
async configure(config) {
|
||||
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;
|
||||
await contributions();
|
||||
},
|
||||
async transformAst(ast, file) {
|
||||
let current = ast;
|
||||
@@ -153,6 +207,18 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
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 hook(name, value) {
|
||||
for (const plugin of plugins) {
|
||||
if (name === "buildStart") await plugin.buildStart?.(context);
|
||||
@@ -162,3 +228,15 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Preserve direct type imports used by older applications.
|
||||
export type {
|
||||
PageAst,
|
||||
WrnDiagnostic,
|
||||
PluginOrder,
|
||||
PluginContext,
|
||||
TransformContext,
|
||||
WrnexusPlugin,
|
||||
PluginInput,
|
||||
PluginRunner,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { extname } from "node:path";
|
||||
import type {
|
||||
ClientRuntimeDefinition,
|
||||
PackageAssetDefinition,
|
||||
PackageStyleDefinition,
|
||||
WrnexusPackageManifest,
|
||||
} from "./types.ts";
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9@][a-z0-9@/._-]*$/i;
|
||||
|
||||
export function assertContributionId(kind: string, id: string): void {
|
||||
if (!id || !ID_PATTERN.test(id) || id.includes("..")) {
|
||||
throw new Error(`WRN-PLUGIN-${kind.toUpperCase()}-ID: invalid id '${id}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultClientRuntimePath(id: string): string {
|
||||
assertContributionId("runtime", id);
|
||||
return `/__wrnexus/assets/${encodeURIComponent(id.replace(/^@/, "").replace(/[/.@]+/g, "-"))}.js`;
|
||||
}
|
||||
|
||||
export function defaultPackageAssetPath(asset: PackageAssetDefinition): string {
|
||||
assertContributionId("asset", asset.id);
|
||||
const extension = asset.entry ? extname(asset.entry) : "";
|
||||
const suffix = extension || extensionForContentType(asset.contentType);
|
||||
const id = encodeURIComponent(asset.id.replace(/^@/, "").replace(/[/.@]+/g, "-"));
|
||||
return `/__wrnexus/assets/${id}${suffix}`;
|
||||
}
|
||||
|
||||
function extensionForContentType(contentType?: string): string {
|
||||
if (!contentType) return "";
|
||||
if (contentType.includes("javascript")) return ".js";
|
||||
if (contentType.includes("css")) return ".css";
|
||||
if (contentType.includes("json")) return ".json";
|
||||
if (contentType.includes("svg")) return ".svg";
|
||||
if (contentType.includes("wav")) return ".wav";
|
||||
if (contentType.includes("png")) return ".png";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function normalizeClientRuntime(
|
||||
runtime: ClientRuntimeDefinition,
|
||||
): ClientRuntimeDefinition & {
|
||||
publicPath: string;
|
||||
type: "module" | "script";
|
||||
load: "eager" | "defer" | "idle";
|
||||
singleton: boolean;
|
||||
} {
|
||||
assertContributionId("runtime", runtime.id);
|
||||
if (!runtime.entry && runtime.source === undefined) {
|
||||
throw new Error(`WRN-PLUGIN-RUNTIME-SOURCE: runtime '${runtime.id}' needs entry or source.`);
|
||||
}
|
||||
const publicPath = runtime.publicPath ?? defaultClientRuntimePath(runtime.id);
|
||||
if (!publicPath.startsWith("/") || publicPath.includes("..")) {
|
||||
throw new Error(`WRN-PLUGIN-RUNTIME-PATH: runtime '${runtime.id}' has an unsafe publicPath.`);
|
||||
}
|
||||
return {
|
||||
...runtime,
|
||||
publicPath,
|
||||
type: runtime.type ?? "module",
|
||||
load: runtime.load ?? "defer",
|
||||
inject: runtime.inject ?? "body-end",
|
||||
singleton: runtime.singleton ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePackageAsset(
|
||||
asset: PackageAssetDefinition,
|
||||
): PackageAssetDefinition & { publicPath: string } {
|
||||
assertContributionId("asset", asset.id);
|
||||
if (!asset.entry && asset.source === undefined) {
|
||||
throw new Error(`WRN-PLUGIN-ASSET-SOURCE: asset '${asset.id}' needs entry or source.`);
|
||||
}
|
||||
const publicPath = asset.publicPath ?? defaultPackageAssetPath(asset);
|
||||
if (!publicPath.startsWith("/") || publicPath.includes("..")) {
|
||||
throw new Error(`WRN-PLUGIN-ASSET-PATH: asset '${asset.id}' has an unsafe publicPath.`);
|
||||
}
|
||||
return { ...asset, publicPath };
|
||||
}
|
||||
|
||||
export function definePackageManifest<T extends WrnexusPackageManifest>(manifest: T): T {
|
||||
if (manifest.name) assertContributionId("package", manifest.name);
|
||||
for (const runtime of manifest.clientRuntimes ?? []) normalizeClientRuntime(runtime);
|
||||
for (const asset of manifest.assets ?? []) normalizePackageAsset(asset);
|
||||
validateStyleIds(manifest.styles ?? []);
|
||||
for (const route of manifest.routes ?? []) {
|
||||
if (!route.path.startsWith("/") || route.path.includes("..") || !route.entry) {
|
||||
throw new Error(`WRN-PLUGIN-ROUTE: invalid ${route.kind} route '${route.path}'.`);
|
||||
}
|
||||
}
|
||||
for (const migration of manifest.migrations ?? []) {
|
||||
assertContributionId("migration", migration.id);
|
||||
if (!migration.entry && migration.source === undefined) {
|
||||
throw new Error(
|
||||
`WRN-PLUGIN-MIGRATION-SOURCE: migration '${migration.id}' needs entry or source.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function validateStyleIds(styles: readonly PackageStyleDefinition[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const style of styles) {
|
||||
assertContributionId("style", style.id);
|
||||
if (seen.has(style.id)) throw new Error(`WRN-PLUGIN-STYLE-DUPLICATE: ${style.id}`);
|
||||
seen.add(style.id);
|
||||
if (!style.entry && !style.source) {
|
||||
throw new Error(`WRN-PLUGIN-STYLE-SOURCE: style '${style.id}' needs entry or source.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function contentTypeForPath(path: string): string {
|
||||
const extension = extname(path).toLowerCase();
|
||||
switch (extension) {
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
return "text/javascript; charset=utf-8";
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8";
|
||||
case ".json":
|
||||
return "application/json; charset=utf-8";
|
||||
case ".svg":
|
||||
return "image/svg+xml; charset=utf-8";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".wav":
|
||||
return "audio/wav";
|
||||
case ".mp3":
|
||||
return "audio/mpeg";
|
||||
case ".woff":
|
||||
return "font/woff";
|
||||
case ".woff2":
|
||||
return "font/woff2";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
|
||||
|
||||
export type PluginOrder = "pre" | "normal" | "post";
|
||||
export type PluginCommand = "dev" | "build" | "test";
|
||||
export type ClientRuntimeType = "module" | "script";
|
||||
export type ClientRuntimeLoad = "eager" | "defer" | "idle";
|
||||
export type ClientRuntimeInject = "head" | "body-end";
|
||||
|
||||
export interface PluginContext {
|
||||
root: string;
|
||||
mode: "development" | "production";
|
||||
command: PluginCommand;
|
||||
profile?: string;
|
||||
metadata: Map<string, unknown>;
|
||||
warn(message: string): void;
|
||||
}
|
||||
|
||||
export interface TransformContext extends PluginContext {
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser runtime owned by a package. The framework serves/bundles it and
|
||||
* injects it only when rendered markup declares `data-wrnexus-runtime="<id>"`.
|
||||
*/
|
||||
export interface ClientRuntimeDefinition {
|
||||
/** Stable, package-wide runtime identifier. */
|
||||
id: string;
|
||||
/** Browser entry file. May be TypeScript; production builds bundle it. */
|
||||
entry?: string;
|
||||
/** Inline browser source. Use this instead of `entry` for generated runtimes. */
|
||||
source?: string;
|
||||
/** Development URL. Defaults to `/__wrnexus/assets/<id>.js`. */
|
||||
publicPath?: string;
|
||||
/** Module scripts are the default. Use `script` for an IIFE/classic script. */
|
||||
type?: ClientRuntimeType;
|
||||
/** Loading policy. Defaults to `defer`. */
|
||||
load?: ClientRuntimeLoad;
|
||||
/** Document injection target. Defaults to `body-end`. */
|
||||
inject?: ClientRuntimeInject;
|
||||
/** Runtime is loaded at most once per document. Defaults to true. */
|
||||
singleton?: boolean;
|
||||
/** Bundle the entry in production. Defaults to true for TS and false for JS. */
|
||||
bundle?: boolean;
|
||||
/** Optional SRI string for externally hosted runtimes. */
|
||||
integrity?: string;
|
||||
crossOrigin?: "anonymous" | "use-credentials";
|
||||
/** Additional safe script attributes. */
|
||||
attributes?: Record<string, string | boolean>;
|
||||
}
|
||||
|
||||
/** A package-owned static/virtual asset served below `/__wrnexus/assets/`. */
|
||||
export interface PackageAssetDefinition {
|
||||
id: string;
|
||||
entry?: string;
|
||||
source?: string | Uint8Array;
|
||||
publicPath?: string;
|
||||
contentType?: string;
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
/** A package-owned stylesheet entry or Tailwind scan source. */
|
||||
export interface PackageStyleDefinition {
|
||||
id: string;
|
||||
entry?: string;
|
||||
/** Glob/directory that application style processors should scan. */
|
||||
source?: string;
|
||||
order?: PluginOrder;
|
||||
}
|
||||
|
||||
/** A package-owned SQL migration file, directory, or inline migration. */
|
||||
export interface PackageMigrationDefinition {
|
||||
/** Stable package-wide identifier used to prevent collisions. */
|
||||
id: string;
|
||||
/** SQL file or directory containing ordered .sql migrations. */
|
||||
entry?: string;
|
||||
/** Inline SQL using the standard -- +up / -- +down markers. */
|
||||
source?: string;
|
||||
/** Named database target. Omit or use "default" for the default database. */
|
||||
database?: string;
|
||||
}
|
||||
|
||||
export interface PackageRouteDefinition {
|
||||
kind: "page" | "api" | "realtime";
|
||||
/** Absolute after discovery; package manifests may use package-relative paths. */
|
||||
entry: string;
|
||||
/** Exact public route pattern, for example `/account` or `/api/captcha/challenge`. */
|
||||
path: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface PluginDevToolbarPanel {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
badge?: number | string;
|
||||
order?: number;
|
||||
issues?: unknown[];
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
componentDirs: string[];
|
||||
clientRuntimes: ClientRuntimeDefinition[];
|
||||
assets: PackageAssetDefinition[];
|
||||
styles: PackageStyleDefinition[];
|
||||
routes: PackageRouteDefinition[];
|
||||
middleware: string[];
|
||||
migrations: PackageMigrationDefinition[];
|
||||
}
|
||||
|
||||
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[];
|
||||
|
||||
/** Component directories automatically added to router discovery. */
|
||||
componentDirs?: string[] | ((context: PluginContext) => string[] | Promise<string[]>);
|
||||
/** Package browser runtimes, lazily injected from rendered runtime markers. */
|
||||
clientRuntimes?:
|
||||
| ClientRuntimeDefinition[]
|
||||
| ((context: PluginContext) => ClientRuntimeDefinition[] | Promise<ClientRuntimeDefinition[]>);
|
||||
/** Package-owned static or generated assets. */
|
||||
assets?:
|
||||
| PackageAssetDefinition[]
|
||||
| ((context: PluginContext) => PackageAssetDefinition[] | Promise<PackageAssetDefinition[]>);
|
||||
/** Stylesheet entries and processor scan sources. */
|
||||
styleSources?:
|
||||
| PackageStyleDefinition[]
|
||||
| ((context: PluginContext) => PackageStyleDefinition[] | Promise<PackageStyleDefinition[]>);
|
||||
/** Package-owned page/API/realtime routes. */
|
||||
routeEntries?:
|
||||
| PackageRouteDefinition[]
|
||||
| ((context: PluginContext) => PackageRouteDefinition[] | Promise<PackageRouteDefinition[]>);
|
||||
/** Package middleware modules, executed before application middleware. */
|
||||
middleware?: string[] | ((context: PluginContext) => string[] | Promise<string[]>);
|
||||
/** Package-owned database migrations. */
|
||||
migrations?:
|
||||
| PackageMigrationDefinition[]
|
||||
| ((
|
||||
context: PluginContext,
|
||||
) => PackageMigrationDefinition[] | Promise<PackageMigrationDefinition[]>);
|
||||
|
||||
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,
|
||||
): PluginDevToolbarPanel[] | Promise<PluginDevToolbarPanel[]>;
|
||||
}
|
||||
|
||||
export type PluginInput = WrnexusPlugin | false | null | undefined | PluginInput[];
|
||||
|
||||
export interface PackagePluginManifest {
|
||||
/** Plugin module relative to the package root. */
|
||||
plugin: string;
|
||||
/** Named factory/export. Defaults to `default`, then `plugin`. */
|
||||
export?: string;
|
||||
/** Invoke the resolved export with no arguments. Defaults to auto-detection. */
|
||||
factory?: boolean;
|
||||
/** Disable automatic discovery while keeping the manifest available to tools. */
|
||||
autoDiscover?: boolean;
|
||||
}
|
||||
|
||||
export interface WrnexusPackageManifest {
|
||||
name?: string;
|
||||
version?: string;
|
||||
plugin?: string | PackagePluginManifest;
|
||||
components?: string[];
|
||||
clientRuntimes?: ClientRuntimeDefinition[];
|
||||
assets?: PackageAssetDefinition[];
|
||||
styles?: PackageStyleDefinition[];
|
||||
routes?: PackageRouteDefinition[];
|
||||
middleware?: string[];
|
||||
migrations?: PackageMigrationDefinition[];
|
||||
}
|
||||
|
||||
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[]>;
|
||||
contributions(): Promise<PluginContributions>;
|
||||
devToolbarPanels(): Promise<PluginDevToolbarPanel[]>;
|
||||
transformRoutes(routes: unknown[]): Promise<unknown[]>;
|
||||
hook(name: "buildStart" | "buildEnd" | "configureServer", value?: unknown): Promise<void>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolvePlugins } from "../src/index.ts";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createPluginRunner, discoverPlugins, resolvePlugins } from "../src/index.ts";
|
||||
|
||||
describe("plugin ordering", () => {
|
||||
test("orders pre, normal, and post plugins", () => {
|
||||
@@ -18,3 +21,188 @@ describe("plugin ordering", () => {
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
function context(root: string) {
|
||||
return {
|
||||
root,
|
||||
mode: "development" as const,
|
||||
command: "dev" as const,
|
||||
metadata: new Map<string, unknown>(),
|
||||
warn() {},
|
||||
};
|
||||
}
|
||||
|
||||
test("normalizes package runtimes, assets, routes, middleware, and migrations", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-plugin-"));
|
||||
try {
|
||||
const runner = createPluginRunner(
|
||||
{
|
||||
name: "system",
|
||||
clientRuntimes: [{ id: "system", source: "window.system = true" }],
|
||||
assets: [{ id: "system-data", source: "{}", contentType: "application/json" }],
|
||||
routeEntries: [{ kind: "api", path: "/api/system", entry: join(root, "route.ts") }],
|
||||
middleware: [join(root, "middleware.ts")],
|
||||
migrations: [{ id: "system-schema", source: "-- +up\nSELECT 1;" }],
|
||||
},
|
||||
context(root),
|
||||
);
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.clientRuntimes[0]?.publicPath).toBe("/__wrnexus/assets/system.js");
|
||||
expect(contributions.assets[0]?.publicPath).toBe("/__wrnexus/assets/system-data.json");
|
||||
expect(contributions.routes[0]?.path).toBe("/api/system");
|
||||
expect(contributions.middleware).toEqual([join(root, "middleware.ts")]);
|
||||
expect(contributions.migrations[0]?.id).toBe("system-schema");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("auto-discovers package.json contributions from workspace dependencies", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-discovery-"));
|
||||
const app = join(root, "apps", "web");
|
||||
const pkg = join(root, "packages", "example-system");
|
||||
try {
|
||||
mkdirSync(join(app, "app", "pages"), { recursive: true });
|
||||
mkdirSync(join(pkg, "components"), { recursive: true });
|
||||
mkdirSync(join(pkg, "assets"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(app, "package.json"),
|
||||
JSON.stringify({ name: "web", dependencies: { "@wrnexus/example-system": "workspace:*" } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(pkg, "components", "Example.wrn"),
|
||||
"component Example { view { <div>Example</div> } }",
|
||||
);
|
||||
writeFileSync(join(pkg, "assets", "runtime.js"), "window.example = true;");
|
||||
writeFileSync(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@wrnexus/example-system",
|
||||
version: "0.4.0",
|
||||
wrnexus: {
|
||||
components: ["./components"],
|
||||
clientRuntimes: [{ id: "example-system", entry: "./assets/runtime.js" }],
|
||||
migrations: [{ id: "example-schema", source: "-- +up\nSELECT 1;" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const discovered = await discoverPlugins(app, undefined);
|
||||
const runner = createPluginRunner(discovered, context(app));
|
||||
const contributions = await runner.contributions();
|
||||
expect(runner.plugins.map((plugin) => plugin.name)).toContain(
|
||||
"@wrnexus/example-system/manifest",
|
||||
);
|
||||
expect(contributions.componentDirs).toEqual([join(pkg, "components")]);
|
||||
expect(contributions.clientRuntimes[0]?.entry).toBe(join(pkg, "assets", "runtime.js"));
|
||||
expect(contributions.migrations[0]?.id).toBe("example-schema");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects duplicate runtime paths and migration ids", async () => {
|
||||
const runner = createPluginRunner(
|
||||
[
|
||||
{
|
||||
name: "one",
|
||||
clientRuntimes: [{ id: "one", source: "", publicPath: "/same.js" }],
|
||||
migrations: [{ id: "schema", source: "SELECT 1" }],
|
||||
},
|
||||
{
|
||||
name: "two",
|
||||
clientRuntimes: [{ id: "two", source: "", publicPath: "/same.js" }],
|
||||
migrations: [{ id: "schema", source: "SELECT 2" }],
|
||||
},
|
||||
],
|
||||
context("."),
|
||||
);
|
||||
await expect(runner.contributions()).rejects.toThrow("WRN-PLUGIN-RUNTIME-PATH-DUPLICATE");
|
||||
});
|
||||
|
||||
test("rejects client-runtime and package-asset path collisions", async () => {
|
||||
const runner = createPluginRunner(
|
||||
[
|
||||
{
|
||||
name: "runtime-owner",
|
||||
clientRuntimes: [{ id: "runtime", source: "", publicPath: "/shared.js" }],
|
||||
},
|
||||
{
|
||||
name: "asset-owner",
|
||||
assets: [
|
||||
{
|
||||
id: "asset",
|
||||
source: "window.asset = true;",
|
||||
contentType: "text/javascript",
|
||||
publicPath: "/shared.js",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
context("."),
|
||||
);
|
||||
|
||||
await expect(runner.contributions()).rejects.toThrow("WRN-PLUGIN-PUBLIC-PATH-DUPLICATE");
|
||||
});
|
||||
|
||||
test("strict discovery surfaces invalid package plugin exports", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-strict-discovery-"));
|
||||
const app = join(root, "apps", "web");
|
||||
const pkg = join(root, "packages", "broken-system");
|
||||
try {
|
||||
mkdirSync(join(app, "app", "pages"), { recursive: true });
|
||||
mkdirSync(pkg, { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(app, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "web",
|
||||
dependencies: { "@wrnexus/broken-system": "workspace:*" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(pkg, "plugin.mjs"), "export const unrelated = true;\n");
|
||||
writeFileSync(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@wrnexus/broken-system",
|
||||
version: "0.4.0",
|
||||
wrnexus: { plugin: "./plugin.mjs" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(discoverPlugins(app, undefined, { strict: true })).rejects.toThrow(
|
||||
"WRN-PLUGIN-DISCOVERY",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders package style contributions by pre, normal, and post", async () => {
|
||||
const runner = createPluginRunner(
|
||||
{
|
||||
name: "styles",
|
||||
styleSources: [
|
||||
{ id: "post", source: "post", order: "post" },
|
||||
{ id: "normal", source: "normal" },
|
||||
{ id: "pre", source: "pre", order: "pre" },
|
||||
{ id: "normal-two", source: "normal-two", order: "normal" },
|
||||
],
|
||||
},
|
||||
context("."),
|
||||
);
|
||||
|
||||
expect((await runner.contributions()).styles.map((style) => style.id)).toEqual([
|
||||
"pre",
|
||||
"normal",
|
||||
"normal-two",
|
||||
"post",
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user