release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+5 -2
View File
@@ -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:*"
+18
View File
@@ -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;
}
+272
View File
@@ -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
View File
@@ -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,
};
+144
View File
@@ -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";
}
}
+203
View File
@@ -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>;
}
+189 -1
View File
@@ -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",
]);
});