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
+282 -25
View File
@@ -21,7 +21,7 @@ import {
statSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { basename, extname, join, resolve } from "node:path";
import { buildRouter, type Route } from "@wrnexus/router";
import { getReactiveRuntime } from "@wrnexus/csr";
import { assertValidAst, generate, parse } from "@wrnexus/compiler";
@@ -40,7 +40,15 @@ import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
import { pathToFileURL } from "node:url";
import { checkPerformanceBudgets } from "@wrnexus/core";
import { createPluginRunner } from "@wrnexus/plugin";
import { currentCliVersion } from "./update-notifier.ts";
import {
createPluginRunner,
discoverPlugins,
contentTypeForPath,
type ClientRuntimeDefinition,
type PackageAssetDefinition,
type PluginContributions,
} from "@wrnexus/plugin";
// Import the production server from the package specifier (not a source path) so
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
@@ -70,7 +78,12 @@ export async function runBuild(appRoot: string): Promise<void> {
}
const config = await loadAppConfig(root);
const pluginRunner = createPluginRunner(config.plugins, {
const discoveredPlugins = await discoverPlugins(root, config.plugins, {
includeDevDependencies: true,
strict: true,
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
const pluginRunner = createPluginRunner(discoveredPlugins, {
root,
mode: "production",
command: "build",
@@ -80,6 +93,8 @@ export async function runBuild(appRoot: string): Promise<void> {
});
await pluginRunner.configure(config as Record<string, unknown>);
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
const pluginContributions = await pluginRunner.contributions();
const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
await pluginRunner.hook("buildStart");
// `.wrn` route files are compiled once into deterministic intermediate modules.
@@ -120,26 +135,90 @@ export async function runBuild(appRoot: string): Promise<void> {
if (n >= 0) console.log(`✓ Queries: ${n} (db/${name}/queries.gen.ts)`);
}
// Bundle DB migrations into the build so the production server can auto-apply
// them on startup (dev auto-migrates from app/db/migrations; prod needs the
// .sql files inside dist/). The default db's migrations go to dist/migrations;
// each named db's to dist/db/<name>/migrations.
// Bundle application and package-owned DB migrations into the build. Package
// files are namespaced by contribution id so independently installed systems
// cannot collide with application migration filenames.
const safeMigrationName = (value: string): string =>
value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_") || "migration";
let hasDefaultMigrations = false;
const namedMigrationDbs = new Set<string>();
const migrationTarget = (database?: string): string => {
const target = database?.trim() || "default";
if (target === "default") {
if (!config.db) {
throw new Error(
"WRN-PLUGIN-MIGRATION-DATABASE: a package targets the default database, but config.db is not configured.",
);
}
hasDefaultMigrations = true;
return join(distDir, "migrations");
}
if (!config.databases?.[target]) {
throw new Error(
`WRN-PLUGIN-MIGRATION-DATABASE: package migration targets unknown database '${target}'.`,
);
}
namedMigrationDbs.add(target);
return join(distDir, "db", target, "migrations");
};
const defaultMigrationsSrc = join(appDir, "db", "migrations");
const hasDefaultMigrations = !!config.db && existsSync(defaultMigrationsSrc);
if (hasDefaultMigrations) {
if (config.db && existsSync(defaultMigrationsSrc)) {
cpSync(defaultMigrationsSrc, join(distDir, "migrations"), { recursive: true });
console.log(`✓ Migrations: dist/migrations`);
hasDefaultMigrations = true;
console.log("✓ Migrations: dist/migrations");
}
const namedMigrationDbs: string[] = [];
for (const name of Object.keys(config.databases ?? {})) {
const src = join(appDir, "db", name, "migrations");
if (!existsSync(src)) continue;
cpSync(src, join(distDir, "db", name, "migrations"), { recursive: true });
namedMigrationDbs.push(name);
const source = join(appDir, "db", name, "migrations");
if (!existsSync(source)) continue;
cpSync(source, join(distDir, "db", name, "migrations"), { recursive: true });
namedMigrationDbs.add(name);
console.log(`✓ Migrations: dist/db/${name}/migrations`);
}
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
for (const migration of pluginContributions.migrations) {
const destination = migrationTarget(migration.database);
mkdirSync(destination, { recursive: true });
const prefix = safeMigrationName(migration.id);
if (migration.source !== undefined) {
writeFileSync(join(destination, `${prefix}.sql`), migration.source, "utf8");
continue;
}
if (!migration.entry || !existsSync(migration.entry)) {
throw new Error(
`WRN-PLUGIN-MIGRATION-MISSING: ${migration.id} points to ${migration.entry ?? "<empty>"}.`,
);
}
const stat = statSync(migration.entry);
if (stat.isDirectory()) {
for (const file of readdirSync(migration.entry)
.filter((name) => name.endsWith(".sql"))
.sort()) {
cpSync(
join(migration.entry, file),
join(destination, `${prefix}__${safeMigrationName(file)}`),
);
}
} else if (stat.isFile() && migration.entry.endsWith(".sql")) {
cpSync(
migration.entry,
join(destination, `${prefix}__${safeMigrationName(basename(migration.entry))}`),
);
} else {
throw new Error(
`WRN-PLUGIN-MIGRATION-ENTRY: ${migration.id} must be a .sql file or directory.`,
);
}
}
if (pluginContributions.migrations.length) {
console.log(`✓ Package migrations: ${pluginContributions.migrations.length}`);
}
const router = buildRouter(appDir, {
componentDirs,
externalRoutes: pluginContributions.routes,
middlewareFiles: pluginContributions.middleware,
});
const wrnFiles = new Set([
...router.pages.map((route) => route.file),
...router.api.map((route) => route.file),
@@ -149,6 +228,17 @@ export async function runBuild(appRoot: string): Promise<void> {
]);
for (const file of wrnFiles) await compileWrn(file);
const assetHash = createHash("sha256");
const emittedPluginAssets = await emitPluginAssets(
pluginContributions,
distDir,
config.build?.sourceMaps === true,
);
for (const asset of emittedPluginAssets.assets) assetHash.update(readFileSync(asset.file));
if (emittedPluginAssets.assets.length) {
console.log(
`✓ Plugin assets: ${emittedPluginAssets.assets.length} (${emittedPluginAssets.runtimes.length} runtimes)`,
);
}
// 1) Components are `.wrn` modules rendered server-side — no browser chunks.
// They are compiled + statically imported into the manifest below.
@@ -201,9 +291,20 @@ export async function runBuild(appRoot: string): Promise<void> {
const styleEntry = findStyleEntry(appDir, root, config.styles?.entry);
let hasStyles = false;
let inlineStyles = "";
if (styleEntry) {
const hasPackageStyleEntries = pluginContributions.styles.some((style) => !!style.entry);
if (styleEntry || hasPackageStyleEntries) {
const css = await renderStyles(
{ entryPath: styleEntry, appDir, appRoot: root, mode: "production" },
{
entryPath: styleEntry,
appDir,
appRoot: root,
mode: "production",
sources: [
...componentDirs,
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
],
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
},
config.styles,
);
assetHash.update(css);
@@ -294,8 +395,8 @@ await createProductionServer(
storage: ${config.storage ? JSON.stringify(config.storage) : "undefined"},
${hasDefaultMigrations ? `migrationsDir: join(import.meta.dir, "migrations"),` : ""}
${
namedMigrationDbs.length
? `databaseMigrationDirs: { ${namedMigrationDbs
namedMigrationDbs.size
? `databaseMigrationDirs: { ${[...namedMigrationDbs]
.map(
(n) =>
`${JSON.stringify(n)}: join(import.meta.dir, "db", ${JSON.stringify(n)}, "migrations")`,
@@ -309,6 +410,8 @@ await createProductionServer(
${hasStyles ? `stylesIncludeFramework: true,` : ""}
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
assetVersion: ${JSON.stringify(assetVersion)},
clientRuntimes: ${JSON.stringify(emittedPluginAssets.runtimes)},
pluginAssets: ${renderProductionPluginAssets(emittedPluginAssets.assets)},
head: ${JSON.stringify(headStr)},
seo: ${JSON.stringify(config.seo ?? {})},
mobile: ${JSON.stringify(config.mobile ?? {})},
@@ -342,10 +445,36 @@ await createProductionServer(
distDir,
publicDir: distPublicDir,
adapter: config.build?.adapter ?? "bun",
routes: router.pages,
routes: [
...router.pages.map((route) => ({ kind: "page" as const, route })),
...router.api.map((route) => ({ kind: "api" as const, route })),
...router.realtime.map((route) => ({ kind: "realtime" as const, route })),
],
runtimeFile: reactivePath,
cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"),
});
report.pluginAssets = emittedPluginAssets.assets.map((asset) => ({
id: asset.id,
publicPath: asset.publicPath,
bytes: statSync(asset.file).size,
runtime: asset.runtime,
}));
report.plugins = pluginRunner.plugins.map((plugin) => ({
name: plugin.name,
version: plugin.version,
}));
report.clientRuntimes = emittedPluginAssets.runtimes.map((runtime) => ({
id: runtime.id,
publicPath: runtime.publicPath!,
type: runtime.type ?? "module",
load: runtime.load ?? "defer",
}));
report.migrations = pluginContributions.migrations.map((migration) => ({
id: migration.id,
database: migration.database ?? "default",
source: migration.entry ?? "inline",
}));
report.componentDirs = componentDirs.map(fwd);
const violations = checkPerformanceBudgets(
config.performance?.budgets ?? {},
report.measurements,
@@ -374,12 +503,139 @@ await createProductionServer(
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
}
interface EmittedPluginAsset {
id: string;
publicPath: string;
file: string;
contentType: string;
runtime: boolean;
immutable: boolean;
}
interface EmittedPluginAssets {
assets: EmittedPluginAsset[];
runtimes: ClientRuntimeDefinition[];
}
function safeAssetName(id: string): string {
return (
id
.replace(/^@/, "")
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || "asset"
);
}
function sourceBytes(source: string | Uint8Array): Uint8Array {
return typeof source === "string" ? new TextEncoder().encode(source) : source;
}
async function compileRuntimeSource(
runtime: ClientRuntimeDefinition,
sourceMaps: boolean,
): Promise<Uint8Array> {
if (runtime.source !== undefined) return sourceBytes(runtime.source);
if (!runtime.entry) throw new Error(`Runtime '${runtime.id}' has no entry.`);
const shouldBundle = runtime.bundle ?? /\.[cm]?tsx?$/.test(runtime.entry);
if (!shouldBundle) return readFileSync(runtime.entry);
const result = await Bun.build({
entrypoints: [runtime.entry],
target: "browser",
format: runtime.type === "script" ? "iife" : "esm",
minify: true,
sourcemap: sourceMaps ? "inline" : "none",
});
if (!result.success || !result.outputs[0]) {
throw new Error(
`Client runtime '${runtime.id}' failed to build:\n${result.logs.map(String).join("\n")}`,
);
}
return new Uint8Array(await result.outputs[0].arrayBuffer());
}
async function rawAssetSource(asset: PackageAssetDefinition): Promise<Uint8Array> {
if (asset.source !== undefined) return sourceBytes(asset.source);
if (!asset.entry) throw new Error(`Asset '${asset.id}' has no entry.`);
return readFileSync(asset.entry);
}
async function emitPluginAssets(
contributions: PluginContributions,
distDir: string,
sourceMaps: boolean,
): Promise<EmittedPluginAssets> {
const outputDir = join(distDir, "plugin-assets");
mkdirSync(outputDir, { recursive: true });
const assets: EmittedPluginAsset[] = [];
const runtimes: ClientRuntimeDefinition[] = [];
for (const runtime of contributions.clientRuntimes) {
const bytes = await compileRuntimeSource(runtime, sourceMaps);
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const filename = `${safeAssetName(runtime.id)}.${hash}.js`;
const file = join(outputDir, filename);
writeFileSync(file, bytes);
const publicPath = `/__wrnexus/assets/${filename}`;
assets.push({
id: runtime.id,
publicPath,
file,
contentType: "text/javascript; charset=utf-8",
runtime: true,
immutable: true,
});
runtimes.push({ ...runtime, entry: undefined, source: undefined, publicPath });
}
for (const asset of contributions.assets) {
const bytes = await rawAssetSource(asset);
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const extension = extname(asset.entry ?? asset.publicPath ?? "") || "";
const filename = `${safeAssetName(asset.id)}.${hash}${extension}`;
const file = join(outputDir, filename);
writeFileSync(file, bytes);
// Package assets keep their declared public URL so component markup, CSS,
// and server responses do not need build-time string rewriting. The disk
// filename is still content-addressed to make deployments atomic.
assets.push({
id: asset.id,
publicPath: asset.publicPath!,
file,
contentType: asset.contentType ?? contentTypeForPath(asset.entry ?? asset.publicPath ?? ""),
runtime: false,
immutable: asset.immutable ?? false,
});
}
return { assets, runtimes };
}
function renderProductionPluginAssets(assets: readonly EmittedPluginAsset[]): string {
if (!assets.length) return "{}";
const entries = assets.map(
(asset) =>
`${JSON.stringify(asset.publicPath)}: { path: join(import.meta.dir, "plugin-assets", ${JSON.stringify(asset.file.split(/[\\/]/).pop())}), contentType: ${JSON.stringify(asset.contentType)}, immutable: ${asset.immutable} }`,
);
return `{ ${entries.join(", ")} }`;
}
interface BuildReport {
frameworkVersion: string;
plugins?: Array<{ name: string; version?: string }>;
pluginAssets?: Array<{ id: string; publicPath: string; bytes: number; runtime: boolean }>;
clientRuntimes?: Array<{ id: string; publicPath: string; type: string; load: string }>;
migrations?: Array<{ id: string; database: string; source: string }>;
componentDirs?: string[];
generatedAt: string;
root: string;
adapter: string;
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
routes: Array<{
kind: "page" | "api" | "realtime";
path: string;
source: string;
sourceBytes: number;
dynamicParams: string[];
}>;
assets: Array<{ file: string; bytes: number }>;
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
budgetViolations: ReturnType<typeof checkPerformanceBudgets>;
@@ -406,7 +662,7 @@ function createBuildReport(input: {
distDir: string;
publicDir: string;
adapter: string;
routes: Route[];
routes: Array<{ kind: "page" | "api" | "realtime"; route: Route }>;
runtimeFile: string;
cssFile: string;
}): BuildReport {
@@ -422,11 +678,12 @@ function createBuildReport(input: {
.map(fileBytes),
);
return {
frameworkVersion: "0.3.0",
frameworkVersion: currentCliVersion(),
generatedAt: new Date().toISOString(),
root: input.root,
adapter: input.adapter,
routes: input.routes.map((route) => ({
routes: input.routes.map(({ kind, route }) => ({
kind,
path: route.raw,
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
sourceBytes: fileBytes(route.file),
+68 -2
View File
@@ -3,6 +3,7 @@ import { extname, join, resolve } from "node:path";
import { diagnose } from "@wrnexus/syntax";
import { buildRouter, findRouteConflicts } from "@wrnexus/router";
import { loadAppConfig, validateAppConfig } from "@wrnexus/styles";
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
export interface DoctorCheck {
name: string;
@@ -85,8 +86,13 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
checks.push({
name: "update marker",
ok: !marker || versionAtLeast(marker, "0.3.0"),
detail: marker ? `project last migrated to ${marker}` : "missing; run `wrnexus update`",
ok: !marker || versionAtLeast(marker, "0.4.0"),
detail:
marker && versionAtLeast(marker, "0.4.0")
? `project last migrated to ${marker}`
: marker
? `project is on ${marker}; run \`wrnexus update 0.4.0\``
: "missing; run `wrnexus update`",
level: "warning",
});
} catch {
@@ -182,6 +188,66 @@ export async function runDoctor(appRoot: string): Promise<boolean> {
: "valid",
level: issues.some((issue) => issue.severity === "error") ? "error" : "warning",
});
const discovered = await discoverPlugins(root, config.plugins, {
includeDevDependencies: true,
strict: true,
warn: (message) =>
checks.push({ name: "package plugin discovery", ok: false, detail: message }),
});
const runner = createPluginRunner(discovered, {
root,
mode: "development",
command: "dev",
metadata: new Map(),
warn: () => {},
});
await runner.configure(config as Record<string, unknown>);
await runner.configResolved(config as Readonly<Record<string, unknown>>);
const contributions = await runner.contributions();
checks.push({
name: "package plugins",
ok: true,
detail: `${runner.plugins.length} plugins, ${contributions.clientRuntimes.length} runtimes, ${contributions.assets.length} assets, ${contributions.routes.length} routes, ${contributions.migrations.length} migrations`,
});
const missingContributions = [
...contributions.componentDirs.map((path) => ({ kind: "component directory", path })),
...contributions.clientRuntimes
.filter((runtime) => runtime.entry)
.map((runtime) => ({ kind: `runtime ${runtime.id}`, path: runtime.entry! })),
...contributions.assets
.filter((asset) => asset.entry)
.map((asset) => ({ kind: `asset ${asset.id}`, path: asset.entry! })),
...contributions.routes.map((route) => ({
kind: `${route.kind} route ${route.path}`,
path: route.entry,
})),
...contributions.middleware.map((path) => ({ kind: "middleware", path })),
...contributions.migrations
.filter((migration) => migration.entry)
.map((migration) => ({ kind: `migration ${migration.id}`, path: migration.entry! })),
].filter((entry) => !existsSync(entry.path));
checks.push({
name: "package contribution files",
ok: missingContributions.length === 0,
detail: missingContributions.length
? missingContributions.map((entry) => `${entry.kind}: ${entry.path}`).join("; ")
: "all discovered contribution files exist",
});
const legacyCaptchaAssets = [
"public/assets/wrnexus/captcha.js",
"public/__wrnexus/captcha.js",
].filter((path) => existsSync(join(root, path)));
checks.push({
name: "legacy CAPTCHA runtime copies",
ok: legacyCaptchaAssets.length === 0,
detail: legacyCaptchaAssets.length
? `remove with wrnexus update 0.4.0: ${legacyCaptchaAssets.join(", ")}`
: "none; CAPTCHA runtime is package-managed",
level: "warning",
});
} catch (error) {
checks.push({
name: "resolved configuration",
+10 -5
View File
@@ -6,7 +6,7 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { uiComponentsDir, uiComponentNames } from "@wrnexus/ui";
import { uiComponentNames, uiComponentPath } from "@wrnexus/ui";
export function runEject(appRoot: string, names: string[]): void {
const root = resolve(appRoot);
@@ -20,12 +20,17 @@ export function runEject(appRoot: string, names: string[]): void {
}
mkdirSync(dest, { recursive: true });
for (const name of names) {
if (!available.includes(name)) {
console.error(`✗ Unknown component "${name}". Available: ${available.join(", ")}`);
for (const requestedName of names) {
const name = available.find(
(componentName) => componentName.toLowerCase() === requestedName.toLowerCase(),
);
if (!name) {
console.error(`✗ Unknown component "${requestedName}". Available: ${available.join(", ")}`);
continue;
}
const src = join(uiComponentsDir(), `${name}.wrn`);
const src = uiComponentPath(name);
const out = join(dest, `${name}.wrn`);
if (existsSync(out)) {
console.error(`${name}: app/components/${name}.wrn already exists — skipped`);
+15
View File
@@ -63,6 +63,8 @@ Usage:
wrnexus doctor [app-dir] Check project structure, versions, syntax, routes, and config
wrnexus config [app-dir] --explain Print the fully resolved profile configuration
wrnexus analyze [app-dir] Inspect dist/build-report.json and performance budgets
wrnexus inspect <target> [app-dir] Inspect packages | plugins | routes | assets | runtimes | styles | migrations | bundle
wrnexus generate system <name> Scaffold a complete framework-native package
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
@@ -136,6 +138,12 @@ async function main(): Promise<void> {
generateDocker(process.cwd());
break;
}
if (rest[0] === "system") {
const { generateSystem } = await import("./system.ts");
const files = generateSystem(process.cwd(), rest[1] ?? "");
console.log(`✓ Created @wrnexus/${rest[1]} (${files.length} files)`);
break;
}
if (rest[0] === "mobile") {
const { generateMobile, mobileOptions } = await import("./mobile.ts");
await generateMobile(process.cwd(), mobileOptions(rest.slice(1)));
@@ -192,6 +200,13 @@ async function main(): Promise<void> {
await runConfigCommand(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
break;
}
case "inspect": {
const target = rest.find((arg) => !arg.startsWith("--"));
const appRoot = rest.filter((arg) => !arg.startsWith("--"))[1] ?? ".";
const { runInspect } = await import("./inspect.ts");
await runInspect(appRoot, target, rest);
break;
}
case "analyze": {
const { runAnalyze } = await import("./analyze.ts");
const healthy = runAnalyze(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
+160
View File
@@ -0,0 +1,160 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
import { loadAppConfig } from "@wrnexus/styles";
import { uiComponentsDir } from "@wrnexus/ui";
export type InspectTarget =
"packages" | "plugins" | "routes" | "assets" | "runtimes" | "styles" | "migrations" | "bundle";
function json(path: string): Record<string, any> | null {
try {
return JSON.parse(readFileSync(path, "utf8")) as Record<string, any>;
} catch {
return null;
}
}
function workspaceRoot(start: string): string {
let current = resolve(start);
while (true) {
if (json(join(current, "package.json"))?.workspaces) return current;
const parent = dirname(current);
if (parent === current) return resolve(start);
current = parent;
}
}
function packageRows(root: string) {
const dirs = [join(root, "packages"), join(root, "services")];
const rows: Array<Record<string, unknown>> = [];
for (const dir of dirs) {
if (!existsSync(dir)) continue;
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (!statSync(path).isDirectory()) continue;
const pkg = json(join(path, "package.json"));
if (!pkg?.name) continue;
rows.push({
name: pkg.name,
version: pkg.version,
private: pkg.private === true,
path: relative(root, path).replace(/\\/g, "/"),
plugin: !!pkg.wrnexus?.plugin,
});
}
}
return rows.sort((a, b) => String(a.name).localeCompare(String(b.name)));
}
export async function inspectProject(appRoot: string, target: InspectTarget): Promise<unknown> {
const root = resolve(appRoot);
const workspace = workspaceRoot(root);
if (target === "packages") return packageRows(workspace);
if (target === "bundle") {
const report = join(root, "dist", "build-report.json");
if (!existsSync(report)) throw new Error("Run `wrnexus build` before inspecting the bundle.");
return json(report);
}
const config = await loadAppConfig(root);
const input = await discoverPlugins(root, config.plugins, {
includeDevDependencies: true,
strict: true,
});
const runner = createPluginRunner(input, {
root,
mode: "development",
command: "dev",
metadata: new Map(),
warn: () => {},
});
await runner.configure(config as Record<string, unknown>);
await runner.configResolved(config as Readonly<Record<string, unknown>>);
const contributions = await runner.contributions();
if (target === "plugins")
return runner.plugins.map((plugin) => ({
name: plugin.name,
version: plugin.version,
enforce: plugin.enforce ?? "normal",
}));
if (target === "assets")
return contributions.assets.map(({ id, publicPath, contentType, immutable }) => ({
id,
publicPath,
contentType,
immutable: immutable ?? false,
}));
if (target === "runtimes")
return contributions.clientRuntimes.map(
({ id, publicPath, type, load, singleton, bundle }) => ({
id,
publicPath,
type,
load,
singleton,
bundle,
}),
);
if (target === "styles")
return contributions.styles.map(({ id, entry, source, order }) => ({
id,
entry,
source,
order: order ?? "normal",
}));
if (target === "migrations")
return contributions.migrations.map(({ id, entry, source, database }) => ({
id,
entry,
inline: source !== undefined,
database: database ?? "default",
}));
const router = buildRouter(join(root, "app"), {
componentDirs: [uiComponentsDir(), ...contributions.componentDirs],
externalRoutes: contributions.routes,
middlewareFiles: contributions.middleware,
});
return {
pages: createRouteManifest(nameRoutes(router.pages)),
api: createRouteManifest(nameRoutes(router.api)),
realtime: createRouteManifest(nameRoutes(router.realtime)),
middleware: router.middlewareFiles.map((file) => relative(root, file).replace(/\\/g, "/")),
components: router.components.map((component) => ({
...component,
file: relative(root, component.file).replace(/\\/g, "/"),
})),
};
}
export async function runInspect(
appRoot: string,
targetArg?: string,
args: string[] = [],
): Promise<void> {
const target = (targetArg ?? "plugins") as InspectTarget;
if (
![
"packages",
"plugins",
"routes",
"assets",
"runtimes",
"styles",
"migrations",
"bundle",
].includes(target)
)
throw new Error(`Unknown inspect target: ${target}`);
const value = await inspectProject(appRoot, target);
if (args.includes("--json")) {
console.log(JSON.stringify(value, null, 2));
return;
}
console.log(`WRNexus ${target}\n`);
if (Array.isArray(value))
for (const row of value)
console.log(
` ${Object.entries(row as Record<string, unknown>)
.map(([key, item]) => `${key}=${String(item)}`)
.join(" ")}`,
);
else console.log(JSON.stringify(value, null, 2));
}
+80
View File
@@ -0,0 +1,80 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
function pascal(value: string): string {
return value
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((part) => part[0]!.toUpperCase() + part.slice(1))
.join("");
}
function camel(value: string): string {
const name = pascal(value);
return name ? name[0]!.toLowerCase() + name.slice(1) : name;
}
function safe(value: string): string {
const name = value
.trim()
.toLowerCase()
.replace(/^@wrnexus\//, "")
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
if (!name) throw new Error("System name is required");
return name;
}
/** Scaffold the standard package/component/runtime/test layout for a WRNexus system. */
export function generateSystem(rootDir: string, input: string): string[] {
const root = resolve(rootDir);
const name = safe(input);
const directory = join(root, "packages", name);
if (existsSync(directory)) throw new Error(`Package already exists: packages/${name}`);
const className = pascal(name);
const functionName = camel(name);
const files: Record<string, string> = {
"package.json":
JSON.stringify(
{
name: `@wrnexus/${name}`,
version: "0.4.0",
type: "module",
main: "./src/index.ts",
exports: { ".": "./src/index.ts", "./plugin": "./src/plugin.ts" },
files: ["src", "components", "assets", "README.md"],
scripts: {
test: "bun test",
typecheck: "tsc --noEmit",
check: "bun run typecheck && bun run test",
},
dependencies: {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
},
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" },
wrnexus: {
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
},
},
null,
2,
) + "\n",
"src/index.ts": `export interface ${className}Options {\n enabled?: boolean;\n}\n\nexport function create${className}(options: ${className}Options = {}) {\n return { enabled: options.enabled !== false };\n}\n`,
"src/plugin.ts": `import { dirname, join } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { definePlugin } from "@wrnexus/plugin";\n\nconst root = dirname(dirname(fileURLToPath(import.meta.url)));\n\nexport function ${functionName}Plugin() {\n return definePlugin({\n name: "@wrnexus/${name}",\n version: "0.4.0",\n componentDirs: [join(root, "components")],\n clientRuntimes: [\n {\n id: "${name}",\n entry: join(root, "assets", "client", "runtime.js"),\n type: "script",\n load: "defer",\n singleton: true,\n bundle: false,\n },\n ],\n styleSources: [{ id: "${name}-components", source: join(root, "components") }],\n });\n}\n\nexport default ${functionName}Plugin;\n`,
[`components/${className}.wrn`]: `component ${className} {\n props {\n class = ""\n color = "primary"\n size = "normal"\n }\n\n view {\n <div\n {...attrs}\n data-wrnexus-runtime="${name}"\n class='{class}'\n >\n ${className}\n </div>\n }\n}\n`,
"assets/client/runtime.js": `(function () {\n var runtimeId = ${JSON.stringify(name)};\n\n function mount(root) {\n (root || document)\n .querySelectorAll('[data-wrnexus-runtime="${name}"]')\n .forEach(function (node) {\n if (node.dataset.wrnexusMounted === runtimeId) return;\n node.dataset.wrnexusMounted = runtimeId;\n });\n }\n\n function unmount(root) {\n (root || document)\n .querySelectorAll('[data-wrnexus-runtime="${name}"]')\n .forEach(function (node) {\n if (node.dataset.wrnexusMounted === runtimeId) delete node.dataset.wrnexusMounted;\n });\n }\n\n window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};\n window.__wrnexusRuntimes[runtimeId] = { mount: mount, unmount: unmount };\n\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", function () { mount(document); }, { once: true });\n } else {\n mount(document);\n }\n})();\n`,
"test/system.test.ts": `import { expect, test } from "bun:test";\nimport { create${className} } from "../src/index.ts";\n\ntest("${name} system initializes", () => {\n expect(create${className}().enabled).toBe(true);\n});\n`,
"README.md": `# @wrnexus/${name}\n\nFramework-native WRNexusJS system package generated by \`wrnexus generate system ${name}\`.\n\nThe component directory and browser runtime are discovered automatically when the package is present in an application's dependencies. No public asset copy or manual script tag is required.\n`,
};
const written: string[] = [];
for (const [file, content] of Object.entries(files)) {
const target = join(directory, file);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content, "utf8");
written.push(target);
}
return written;
}
+88 -4
View File
@@ -23,6 +23,7 @@ import {
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
@@ -971,7 +972,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.3.3",
id: "component-fix",
id: "component-fix-0-3-3",
description: "UI component fixes.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
@@ -979,7 +980,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.3.4",
id: "component-fix",
id: "component-fix-0-3-4",
description: "UI component fixes.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
@@ -987,7 +988,7 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.3.5",
id: "new-component-added",
id: "new-component-added-0-3-5",
description: "UI component Added.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
@@ -995,12 +996,95 @@ const MIGRATIONS: Migration[] = [
},
{
version: "0.3.6",
id: "new-component-added",
id: "new-component-added-0-3-6",
description: "UI component Added.",
apply() {
// Compiler-only fix. Existing WRN source files require no migration.
},
},
{
version: "0.4.0",
id: "package-runtime-and-asset-platform",
description:
"Enables automatic package discovery, package components/routes/migrations, and page-scoped client runtime injection without manually copied JavaScript assets.",
apply(ctx) {
const packageFile = join(ctx.appRoot, "package.json");
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as Record<string, any>;
const scripts = (pkg.scripts ??= {});
const additions: Record<string, string> = {
"inspect:plugins": "wrnexus inspect plugins .",
"inspect:runtimes": "wrnexus inspect runtimes .",
"inspect:assets": "wrnexus inspect assets .",
"inspect:routes": "wrnexus inspect routes .",
};
const addedScripts: string[] = [];
for (const [name, command] of Object.entries(additions)) {
if (!scripts[name]) {
scripts[name] = command;
addedScripts.push(name);
}
}
if (addedScripts.length) ctx.log(`+ package scripts: ${addedScripts.join(", ")}`);
if (!ctx.dryRun && addedScripts.length) {
writeFileSync(packageFile, JSON.stringify(pkg, null, 2) + "\n", "utf8");
}
const changedFiles: string[] = [];
const legacyCaptchaScript =
/\s*<script\b[^>]*\bsrc\s*=\s*["']\/(?:__wrnexus\/captcha|assets\/wrnexus\/captcha)\.js(?:\?[^"']*)?["'][^>]*>(?:\s*<\/script>)?\s*/gi;
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
const before = readFileSync(file, "utf8");
const after = before.replace(legacyCaptchaScript, "\n");
if (after === before) continue;
const relativeFile = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
changedFiles.push(relativeFile);
ctx.log(`~ removed legacy CAPTCHA script tag from ${relativeFile}`);
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
}
const movedAssets: string[] = [];
for (const relativeFile of [
"public/assets/wrnexus/captcha.js",
"public/__wrnexus/captcha.js",
]) {
const source = join(ctx.appRoot, relativeFile);
if (!existsSync(source)) continue;
const destination = join(ctx.appRoot, ".wrnexus", "legacy-assets", "0.4.0", relativeFile);
movedAssets.push(relativeFile);
ctx.log(`~ archived legacy ${relativeFile}`);
if (!ctx.dryRun) {
mkdirSync(dirname(destination), { recursive: true });
cpSync(source, destination);
rmSync(source, { force: true });
}
}
const reportFile = join(ctx.appRoot, ".wrnexus", "migrations", "0.4.0.json");
ctx.log(
`+ .wrnexus/migrations/0.4.0.json (${changedFiles.length} source files, ${movedAssets.length} legacy assets)`,
);
if (!ctx.dryRun) {
mkdirSync(dirname(reportFile), { recursive: true });
writeFileSync(
reportFile,
JSON.stringify(
{
version: "0.4.0",
from: ctx.from,
appliedAt: new Date().toISOString(),
changedFiles,
movedAssets,
packageRuntimeDiscovery: true,
manualCaptchaRuntimeRequired: false,
},
null,
2,
) + "\n",
"utf8",
);
}
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */