@ordojs/cli
Version:
Command-line interface for OrdoJS framework
570 lines (569 loc) • 26.5 kB
JavaScript
/**
* @fileoverview OrdoJS CLI - Build command
*/
import { CodeSplitter, FileSystemRouter, OrdoJSCSSInJSCompiler, OrdoJSCSSOptimizer, OrdoJSCompiler, OrdoJSLexer, OrdoJSParser, OrdoJSSSR } from '@ordojs/core';
import { Command } from 'commander';
import path from 'path';
import { AssetOptimizer } from '../utils/asset-optimizer.js';
import { DeploymentAdapterManager } from '../utils/deployment/adapter-manager.js';
import { AWSLambdaAdapter, NetlifyAdapter, VercelAdapter } from '../utils/deployment/index.js';
import { copyFile, mkdir, readFile, stat, writeFile } from '../utils/fs.js';
import { CLIError, ErrorType, logger } from '../utils/index.js';
/**
* Register the build command
*/
export function registerBuildCommand(program) {
program
.command('build')
.description('Build an OrdoJS component or application')
.argument('<file>', 'Path to the OrdoJS file (.atom)')
.option('-o, --out <dir>', 'Output directory', 'dist')
.option('-m, --minify', 'Minify output', false)
.option('--no-sourcemap', 'Disable source maps')
.option('-t, --target <target>', 'Compilation target', 'es2022')
.option('--static', 'Generate static site (SSG mode)')
.option('--routes <dir>', 'Routes directory for SSG', 'src/routes')
.option('--public <dir>', 'Public assets directory', 'public')
.option('--production', 'Enable production mode with all optimizations', false)
.option('--analyze', 'Generate bundle analysis report', false)
.option('--brotli', 'Generate Brotli compressed assets', false)
.option('--gzip', 'Generate Gzip compressed assets', false)
.option('--no-optimization', 'Disable all optimizations')
.option('--bundle-report', 'Generate bundle size report', false)
.option('--deploy <adapter>', 'Prepare deployment for specified adapter (vercel, netlify, aws-lambda)')
.action(async (file, options) => {
try {
// If production mode is enabled, set all optimization flags
if (options.production) {
options.minify = true;
options.brotli = true;
options.gzip = true;
options.analyze = true;
options.bundleReport = true;
options.optimization = true;
}
if (options.static) {
await staticBuildCommand(file, options);
}
else {
await buildCommand(file, options);
}
}
catch (error) {
if (error instanceof CLIError) {
logger.error(`${error.type.toUpperCase()} ERROR: ${error.message}`);
if (error.code) {
logger.error(`Error code: ${error.code}`);
}
if (error.suggestions && error.suggestions.length > 0) {
logger.info('Suggestions:');
error.suggestions.forEach(suggestion => {
logger.info(` - ${suggestion}`);
});
}
}
else {
logger.error(`Build failed: ${error instanceof Error ? error.message : String(error)}`);
}
process.exit(1);
}
});
}
/**
* Build command implementation
*/
export async function buildCommand(file, options) {
logger.info(`Building ${file}...`);
try {
// Validate file extension
if (!file.endsWith('.ordo')) {
throw new CLIError('File must have .ordo extension', ErrorType.VALIDATION, 'CLI-001', [
'Use a file with .ordo extension',
'Example: ordojs build src/component.ordo'
]);
}
// Check if file exists
try {
await readFile(file);
}
catch (error) {
throw new CLIError(`File not found: ${file}`, ErrorType.VALIDATION, 'CLI-002', [
'Check if the file exists',
'Make sure you have the correct path'
]);
}
// Read the source file
const source = await readFile(file);
// Create compiler with options
const compiler = new OrdoJSCompiler({
target: options.target,
optimize: true,
sourceMaps: options.sourcemap,
minify: options.minify
});
// Compile the source
logger.info('Compiling...');
const result = compiler.compile(source);
if (!result.success) {
throw new CLIError('Compilation failed', ErrorType.COMPILATION, 'CLI-003', result.errors.map(error => error));
}
// Optimize CSS if present
logger.info('Optimizing CSS...');
const cssOptimizer = new OrdoJSCSSOptimizer({
removeUnusedRules: true,
minify: options.minify,
mergeDuplicateRules: true,
removeRedundantDeclarations: true,
optimizeShorthands: true,
removeEmptyRules: true,
sortDeclarations: true
});
// Initialize CSS-in-JS compiler for any CSS-in-JS expressions
const cssInJSCompiler = new OrdoJSCSSInJSCompiler({
scoped: true,
classPrefix: 'ordojs',
optimize: options.minify,
generateSourceMaps: options.sourcemap
});
// Create output directory if it doesn't exist
await mkdir(options.out, { recursive: true });
// Write output files
const baseName = path.basename(file, '.ordo');
if (result.output) {
// Write JavaScript output
await writeFile(path.join(options.out, `${baseName}.js`), result.output);
logger.success(`Generated ${path.join(options.out, `${baseName}.js`)}`);
// Generate HTML file for the component
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${baseName}</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import { ${baseName} } from './${baseName}.js';
// Mount the component
const app = ${baseName}();
app.mount(document.getElementById('app'));
</script>
</body>
</html>`;
await writeFile(path.join(options.out, `${baseName}.html`), htmlContent);
logger.success(`Generated ${path.join(options.out, `${baseName}.html`)}`);
}
// Generate source maps if enabled
if (options.sourcemap && result.output) {
// Simple source map generation
const sourceMap = {
version: 3,
file: `${baseName}.js`,
sources: [file],
names: [],
mappings: '',
sourceContent: [source]
};
await writeFile(path.join(options.out, `${baseName}.js.map`), JSON.stringify(sourceMap));
logger.success(`Generated ${path.join(options.out, `${baseName}.js.map`)}`);
}
// Apply production optimizations if enabled
if (options.production || options.brotli || options.gzip || options.analyze || options.bundleReport) {
logger.info('Applying production optimizations...');
// Initialize asset optimizer with options
const assetOptimizer = new AssetOptimizer({
minifyJs: options.minify,
minifyCss: options.minify,
brotli: (options.brotli || options.production) ?? false,
gzip: (options.gzip || options.production) ?? false,
sizeReport: (options.bundleReport || options.analyze || options.production) ?? false
});
// Optimize all assets in the output directory
logger.info('Optimizing assets...');
const optimizationResults = await assetOptimizer.optimizeDirectory(options.out);
// Generate and display size report if enabled
if (options.bundleReport || options.analyze || options.production) {
const sizeReport = assetOptimizer.generateSizeReport(optimizationResults);
logger.info(sizeReport);
// Write size report to file
await writeFile(path.join(options.out, 'size-report.txt'), sizeReport);
logger.success(`Generated ${path.join(options.out, 'size-report.txt')}`);
}
// Log compression statistics
logger.success(`Assets optimized: ${optimizationResults.assets.length} files`);
logger.success(`Total size reduction: ${Math.round(optimizationResults.overallCompressionRatio * 100)}%`);
logger.success(`Original size: ${optimizationResults.totalOriginalSizeHuman}`);
logger.success(`Optimized size: ${optimizationResults.totalMinifiedSizeHuman}`);
if (options.brotli || options.production) {
logger.success(`Brotli compressed size: ${optimizationResults.totalBrotliSizeHuman}`);
}
if (options.gzip || options.production) {
logger.success(`Gzip compressed size: ${optimizationResults.totalGzipSizeHuman}`);
}
}
logger.success(`Build completed successfully`);
// Handle deployment if --deploy option is specified
if (options.deploy) {
logger.info(`Preparing deployment with ${options.deploy} adapter...`);
// Initialize deployment adapter manager
const adapterManager = new DeploymentAdapterManager();
// Register available adapters
adapterManager.registerAdapter(new VercelAdapter());
adapterManager.registerAdapter(new NetlifyAdapter());
adapterManager.registerAdapter(new AWSLambdaAdapter());
// Check if the specified adapter exists
const adapter = adapterManager.getAdapter(options.deploy);
if (!adapter) {
throw new CLIError(`Adapter '${options.deploy}' not found`, ErrorType.VALIDATION, 'CLI-102', [
'Available adapters: vercel, netlify, aws-lambda',
'Example: ordojs build --deploy vercel'
]);
}
// Create deployment configuration
const deploymentConfig = {
outputDir: options.out,
isStatic: false,
includeServerFunctions: false,
env: {
NODE_ENV: 'production'
}
};
// Prepare deployment
const result = await adapter.prepareDeployment(deploymentConfig);
if (!result.success) {
throw new CLIError(`Deployment preparation failed: ${result.error}`, ErrorType.DEPLOYMENT, 'CLI-105');
}
// Log generated files
logger.info('Generated deployment files:');
for (const file of result.generatedFiles) {
logger.info(` - ${file.path}`);
}
// Display deployment instructions
logger.info('\nDeployment Instructions:');
logger.info(result.instructions);
// Display deployment URL if available
if (result.deploymentUrl) {
logger.success(`\nDeployment URL: ${result.deploymentUrl}`);
}
logger.success('Deployment preparation completed successfully');
}
}
catch (error) {
if (error instanceof CLIError) {
logger.error(`${error.type.toUpperCase()} ERROR: ${error.message}`);
if (error.code) {
logger.error(`Error code: ${error.code}`);
}
if (error.suggestions && error.suggestions.length > 0) {
logger.info('Suggestions:');
error.suggestions.forEach(suggestion => {
logger.info(` - ${suggestion}`);
});
}
}
else {
logger.error(`Build failed: ${error instanceof Error ? error.message : String(error)}`);
}
process.exit(1);
}
}
/**
* Static site generation (SSG) build command implementation
*/
export async function staticBuildCommand(entryFile, options) {
logger.info(`Building static site from ${entryFile}...`);
try {
// Validate file extension
if (!entryFile.endsWith('.ordo')) {
throw new CLIError('Entry file must have .ordo extension', ErrorType.VALIDATION, 'CLI-001', [
'Use a file with .ordo extension',
'Example: ordojs build --static src/app.ordo'
]);
}
// Check if entry file exists
try {
await readFile(entryFile);
}
catch (error) {
throw new CLIError(`Entry file not found: ${entryFile}`, ErrorType.VALIDATION, 'CLI-002', [
'Check if the file exists',
'Make sure you have the correct path'
]);
}
// Create output directory if it doesn't exist
await mkdir(options.out, { recursive: true });
// Read the entry file
const entrySource = await readFile(entryFile);
// Parse the entry file
const lexer = new OrdoJSLexer(entrySource);
const tokens = lexer.tokenize();
const parser = new OrdoJSParser(tokens);
const entryAst = parser.parse();
// Create SSR engine
const ssrEngine = new OrdoJSSSR({
includeHydrationMarkers: true,
includeHydrationData: true
});
// Register the entry component
ssrEngine.registerComponent(entryAst);
// Initialize file-system router
logger.info(`Scanning routes directory: ${options.routes}`);
const router = new FileSystemRouter({
routesDir: options.routes,
extensions: ['.ordo'],
generateCatchAll: true,
baseUrl: '/'
});
// Generate routes from file system
let routes;
try {
routes = await router.generateRoutes();
logger.info(`Found ${routes.length} routes`);
}
catch (error) {
throw new CLIError(`Failed to generate routes: ${error instanceof Error ? error.message : String(error)}`, ErrorType.VALIDATION, 'CLI-004', [
'Check if the routes directory exists',
'Ensure route files have valid .atom syntax',
'Use --routes option to specify a different routes directory'
]);
}
// Register all route components with SSR engine
for (const route of routes) {
ssrEngine.registerComponent(route.ast);
logger.info(`Registered route: ${route.path} -> ${route.componentName}`);
}
// Configure SSR engine with route configurations
ssrEngine.options.routes = routes.map((route) => ({
path: route.path,
component: route.componentName,
layout: route.layout
}));
// Generate static HTML for each route
logger.info('Generating static HTML for routes...');
for (const route of routes) {
// Create the output directory for this route
const routeOutputDir = path.join(options.out, route.path === '/' ? '' : route.path);
await mkdir(routeOutputDir, { recursive: true });
// Generate HTML for this route
const html = await ssrEngine.renderRoute(route.path);
// Write the HTML file
const outputPath = path.join(routeOutputDir, 'index.html');
await writeFile(outputPath, html);
logger.success(`Generated ${outputPath}`);
}
// Copy public assets if the directory exists
try {
const publicStats = await stat(options.public);
if (publicStats.isDirectory()) {
logger.info(`Copying public assets from ${options.public}...`);
// Get all files in the public directory recursively
async function getAllFiles(dir) {
const files = [];
const items = await readdir(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
files.push(...await getAllFiles(fullPath));
}
else {
files.push(fullPath);
}
}
return files;
}
const publicFiles = await getAllFiles(options.public);
for (const publicFile of publicFiles) {
const stats = await stat(publicFile);
if (stats.isFile()) {
const relativePath = path.relative(options.public, publicFile);
const outputPath = path.join(options.out, relativePath);
// Create directory if it doesn't exist
const outputDir = path.dirname(outputPath);
await mkdir(outputDir, { recursive: true });
// Copy the file
await copyFile(publicFile, outputPath);
logger.info(`Copied ${relativePath}`);
}
}
}
}
catch (error) {
logger.warn(`Public directory not found: ${options.public}`);
}
// Generate client-side JavaScript bundle
logger.info('Generating client-side JavaScript bundle...');
// Create compiler with options
const compiler = new OrdoJSCompiler({
target: options.target,
optimize: true,
sourceMaps: options.sourcemap,
minify: options.minify
});
// Compile the entry file
const result = compiler.compile(entrySource);
if (!result.success) {
throw new CLIError('Client-side compilation failed', ErrorType.COMPILATION, 'CLI-003', result.errors.map(error => error));
}
// Optimize CSS for static site generation
logger.info('Optimizing CSS for static site...');
const cssOptimizer = new OrdoJSCSSOptimizer({
removeUnusedRules: true,
minify: options.minify,
mergeDuplicateRules: true,
removeRedundantDeclarations: true,
optimizeShorthands: true,
removeEmptyRules: true,
sortDeclarations: true
});
// Initialize CSS-in-JS compiler for static site
const cssInJSCompiler = new OrdoJSCSSInJSCompiler({
scoped: true,
classPrefix: 'ordojs',
optimize: options.minify,
generateSourceMaps: options.sourcemap
});
// Generate navigation utilities
logger.info('Generating navigation utilities...');
const navigationUtils = router.generateNavigationUtils();
await writeFile(path.join(options.out, 'router.js'), navigationUtils);
logger.success(`Generated ${path.join(options.out, 'router.js')}`);
// Generate advanced code splitting configuration using CodeSplitter
logger.info('Generating code splitting configuration...');
// Create code splitter
const codeSplitter = new CodeSplitter({
enabled: true,
chunkSizeThreshold: 50000, // 50KB
routeBasedSplitting: true,
componentBasedSplitting: true,
maxChunks: 10,
alwaysIncludeInMain: []
});
// Register all components with the code splitter
for (const route of routes) {
codeSplitter.registerComponent(route.ast);
}
// Register routes for route-based splitting
codeSplitter.registerRoutes(routes);
// Analyze for code splitting
const codeSplittingResult = codeSplitter.analyze();
// Write code splitting configuration
await writeFile(path.join(options.out, 'code-splitting.json'), JSON.stringify(codeSplittingResult.chunks, null, 2));
logger.success(`Generated ${path.join(options.out, 'code-splitting.json')}`);
// Write lazy loading utilities
await writeFile(path.join(options.out, 'lazy-loading.js'), codeSplittingResult.lazyLoadingCode);
logger.success(`Generated ${path.join(options.out, 'lazy-loading.js')}`);
// Write client-side JavaScript
const baseName = path.basename(entryFile, '.atom');
if (result.output) {
// Write JavaScript output
await writeFile(path.join(options.out, `${baseName}.js`), result.output);
logger.success(`Generated ${path.join(options.out, `${baseName}.js`)}`);
// Generate source maps if enabled
if (options.sourcemap) {
// Simple source map generation
const sourceMap = {
version: 3,
file: `${baseName}.js`,
sources: [entryFile],
names: [],
mappings: '',
sourceContent: [entrySource]
};
await writeFile(path.join(options.out, `${baseName}.js.map`), JSON.stringify(sourceMap));
logger.success(`Generated ${path.join(options.out, `${baseName}.js.map`)}`);
}
}
// Apply production optimizations if enabled
if (options.production || options.brotli || options.gzip || options.analyze || options.bundleReport) {
logger.info('Applying production optimizations for static site...');
// Initialize asset optimizer with options
const assetOptimizer = new AssetOptimizer({
minifyJs: options.minify,
minifyCss: options.minify,
brotli: (options.brotli || options.production) ?? false,
gzip: (options.gzip || options.production) ?? false,
sizeReport: (options.bundleReport || options.analyze || options.production) ?? false
});
// Optimize all assets in the output directory
logger.info('Optimizing static site assets...');
const optimizationResults = await assetOptimizer.optimizeDirectory(options.out);
// Generate and display size report if enabled
if (options.bundleReport || options.analyze || options.production) {
const sizeReport = assetOptimizer.generateSizeReport(optimizationResults);
logger.info(sizeReport);
// Write size report to file
await writeFile(path.join(options.out, 'size-report.txt'), sizeReport);
logger.success(`Generated ${path.join(options.out, 'size-report.txt')}`);
}
// Log compression statistics
logger.success(`Assets optimized: ${optimizationResults.assets.length} files`);
logger.success(`Total size reduction: ${Math.round(optimizationResults.overallCompressionRatio * 100)}%`);
logger.success(`Original size: ${optimizationResults.totalOriginalSizeHuman}`);
logger.success(`Optimized size: ${optimizationResults.totalMinifiedSizeHuman}`);
if (options.brotli || options.production) {
logger.success(`Brotli compressed size: ${optimizationResults.totalBrotliSizeHuman}`);
}
if (options.gzip || options.production) {
logger.success(`Gzip compressed size: ${optimizationResults.totalGzipSizeHuman}`);
}
}
logger.success(`Static site generation completed successfully`);
logger.info(`Output directory: ${path.resolve(options.out)}`);
// Handle deployment if --deploy option is specified
if (options.deploy) {
logger.info(`Preparing deployment with ${options.deploy} adapter...`);
// Initialize deployment adapter manager
const adapterManager = new DeploymentAdapterManager();
// Register available adapters
adapterManager.registerAdapter(new VercelAdapter());
adapterManager.registerAdapter(new NetlifyAdapter());
adapterManager.registerAdapter(new AWSLambdaAdapter());
// Check if the specified adapter exists
const adapter = adapterManager.getAdapter(options.deploy);
if (!adapter) {
throw new CLIError(`Adapter '${options.deploy}' not found`, ErrorType.VALIDATION, 'CLI-102', [
'Available adapters: vercel, netlify, aws-lambda',
'Example: ordojs build --static --deploy vercel'
]);
}
// Create deployment configuration
const deploymentConfig = {
outputDir: options.out,
isStatic: true,
includeServerFunctions: false,
env: {
NODE_ENV: 'production'
}
};
// Prepare deployment
const result = await adapter.prepareDeployment(deploymentConfig);
if (!result.success) {
throw new CLIError(`Deployment preparation failed: ${result.error}`, ErrorType.DEPLOYMENT, 'CLI-105');
}
// Log generated files
logger.info('Generated deployment files:');
for (const file of result.generatedFiles) {
logger.info(` - ${file.path}`);
}
// Display deployment instructions
logger.info('\nDeployment Instructions:');
logger.info(result.instructions);
// Display deployment URL if available
if (result.deploymentUrl) {
logger.success(`\nDeployment URL: ${result.deploymentUrl}`);
}
logger.success('Deployment preparation completed successfully');
}
}
catch (error) {
if (error instanceof CLIError) {
throw error;
}
else {
throw new CLIError(`Static site generation failed: ${error instanceof Error ? error.message : String(error)}`, ErrorType.COMPILATION, 'CLI-005');
}
}
}
//# sourceMappingURL=build.js.map