UNPKG

@ordojs/cli

Version:

Command-line interface for OrdoJS framework

570 lines (569 loc) 26.5 kB
/** * @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