release: WRNexusJS 0.4.0
This commit is contained in:
+282
-25
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user