[files], 'app' => [files]] */ protected array $bundle_files = []; /** * Watch files organized by vendor/app */ protected array $watch_files = []; /** * CDN assets from bundles */ protected array $cdn_assets = ['js' => [], 'css' => []]; /** * Cache keys for vendor/app */ protected array $cache_keys = []; /** * NPM includes to add to vendor JS */ protected array $npm_includes = []; /** * Bundle configuration */ protected array $config = []; /** * Files already included (deduplication) */ protected array $included_files = []; /** * Include paths for route extraction */ protected array $include_routes = []; /** * Bundle name being compiled */ protected string $bundle_name = ''; /** * Whether we're in production mode */ protected bool $is_production = false; /** * Track resolved includes to prevent duplicates * This tracks bundle classes, aliases, and paths that have been processed */ protected array $resolved_includes = []; /** * Compiled jqhtml files (separated during ordering for special placement) */ protected array $jqhtml_compiled_files = []; /** * Mapping from babel-transformed files to their original source files * ['storage/rsx-tmp/babel_xxx.js' => 'app/RSpade/Core/Js/SomeFile.js'] */ protected array $babel_file_mapping = []; /** * Compile a bundle */ public function compile(string $bundle_class, array $options = []): array { $this->bundle_name = $this->_get_bundle_name($bundle_class); $this->is_production = app()->environment('production'); console_debug('BUNDLE', "Compiling {$this->bundle_name} (production: " . ($this->is_production ? 'yes' : 'no') . ')'); // Step 1: Check production cache if ($this->is_production) { $existing = $this->_check_production_cache(); if ($existing) { console_debug('BUNDLE', 'Using existing production bundle'); return $existing; } } // Step 2: Mark the bundle we're compiling as already resolved $this->resolved_includes[$bundle_class] = true; // Step 3: Process required bundles first $this->_process_required_bundles(); // Step 4: Resolve all bundle includes to get flat file list $this->_resolve_bundle($bundle_class); // Step 5: Always split into vendor/app $this->_split_vendor_app(); // Step 6: Check individual bundle caches $need_compile = $this->_check_bundle_caches(); console_debug('BUNDLE', 'Need compile: ' . json_encode($need_compile)); // Step 7-10: Process bundles that need compilation if (!empty($need_compile)) { console_debug('BUNDLE', 'Processing bundles: ' . json_encode($need_compile)); $this->_process_bundles($need_compile); } else { console_debug('BUNDLE', 'No bundles need processing, using cache'); } // Step 11: Compile final output files for types that need it $outputs = $this->_compile_outputs($need_compile); // Step 12: Add existing cached outputs for types that don't need compilation foreach (['vendor', 'app'] as $type) { if (!in_array($type, $need_compile)) { // This type doesn't need compilation, get existing files $cache_key = $this->cache_keys[$type] ?? null; if ($cache_key) { $short_key = substr($cache_key, 0, 8); $bundle_dir = storage_path('rsx-build/bundles'); $js_file = "{$this->bundle_name}__{$type}.{$short_key}.js"; $css_file = "{$this->bundle_name}__{$type}.{$short_key}.css"; if (file_exists("{$bundle_dir}/{$js_file}")) { $outputs["{$type}_js"] = $js_file; } if (file_exists("{$bundle_dir}/{$css_file}")) { $outputs["{$type}_css"] = $css_file; } } } } // Step 13: Return data for render() method (not CLI) // Include CDN assets and proper file paths for HTML generation $bundle_dir = storage_path('rsx-build/bundles'); $result = []; // Add CDN assets if (!empty($this->cdn_assets['js'])) { $result['cdn_js'] = $this->cdn_assets['js']; } if (!empty($this->cdn_assets['css'])) { $result['cdn_css'] = $this->cdn_assets['css']; } // Add bundle file paths for development if (!$this->is_production) { if (isset($outputs['vendor_js'])) { $result['vendor_js_bundle_path'] = $outputs['vendor_js']; } if (isset($outputs['app_js'])) { $result['app_js_bundle_path'] = $outputs['app_js']; } if (isset($outputs['vendor_css'])) { $result['vendor_css_bundle_path'] = $outputs['vendor_css']; } if (isset($outputs['app_css'])) { $result['app_css_bundle_path'] = $outputs['app_css']; } } else { // Production mode - simple concatenation of vendor + app $js_content = ''; if (isset($outputs['vendor_js'])) { $js_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_js']}"); } if (isset($outputs['app_js'])) { // Simple concatenation with newline separator if ($js_content) { $js_content .= "\n"; } $js_content .= file_get_contents("{$bundle_dir}/{$outputs['app_js']}"); } $css_content = ''; if (isset($outputs['vendor_css'])) { $css_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_css']}"); } if (isset($outputs['app_css'])) { // Simple concatenation with newline separator if ($css_content) { $css_content .= "\n"; } $css_content .= file_get_contents("{$bundle_dir}/{$outputs['app_css']}"); } // Write combined files with content hash if ($js_content) { $js_hash = substr(md5($js_content), 0, 16); $js_file = "app.{$js_hash}.js"; file_put_contents("{$bundle_dir}/{$js_file}", $js_content); $result['js_bundle_path'] = $js_file; } if ($css_content) { $css_hash = substr(md5($css_content), 0, 16); $css_file = "app.{$css_hash}.css"; file_put_contents("{$bundle_dir}/{$css_file}", $css_content); $result['css_bundle_path'] = $css_file; } } // Add config if present if (!empty($this->config)) { $result['bundle_data'] = $this->config; } return $result; } // ------------------------------------------------------------------------ // ---- Private / Protected Methods: // ------------------------------------------------------------------------ /** * Check for JavaScript naming conflicts within files being bundled * * This checks for: * - Duplicate function names * - Duplicate const names * - Duplicate class names * - Conflicts between functions, consts, and classes * * Only checks the subset of files being bundled, not the entire manifest. * Throws exception on first conflict found to prevent bundle compilation. * * @param array $types Types being compiled (vendor/app) * @throws \App\RSpade\Core\Exceptions\YoureDoingItWrongException */ protected function _check_js_naming_conflicts(array $types): void { // Collect all JavaScript files from types being compiled $js_files = []; foreach ($types as $type) { $files = $this->bundle_files[$type] ?? []; foreach ($files as $file) { // Only check JavaScript files if (str_ends_with($file, '.js')) { $js_files[] = $file; } } } if (empty($js_files)) { return; // No JS files to check } console_debug('BUNDLE', 'Checking JS naming conflicts for ' . count($js_files) . ' files'); // Get manifest data $manifest_files = Manifest::get_all(); // Track all names across the bundle $all_names = []; // name => ['type' => 'function'|'const'|'class', 'file' => path] // Process each JS file in the bundle foreach ($js_files as $js_file) { // Convert absolute path to relative for manifest lookup $relative_path = str_replace(base_path() . '/', '', $js_file); if (!isset($manifest_files[$relative_path])) { continue; // File not in manifest, skip } $metadata = $manifest_files[$relative_path]; // Check global functions if (!empty($metadata['global_function_names'])) { foreach ($metadata['global_function_names'] as $func_name) { if (isset($all_names[$func_name])) { $this->_throw_js_naming_conflict( $func_name, 'function', $relative_path, $all_names[$func_name]['type'], $all_names[$func_name]['file'] ); } $all_names[$func_name] = ['type' => 'function', 'file' => $relative_path]; } } // Check global consts if (!empty($metadata['global_const_names'])) { foreach ($metadata['global_const_names'] as $const_name) { if (isset($all_names[$const_name])) { $this->_throw_js_naming_conflict( $const_name, 'const', $relative_path, $all_names[$const_name]['type'], $all_names[$const_name]['file'] ); } $all_names[$const_name] = ['type' => 'const', 'file' => $relative_path]; } } // Check class names (they share namespace with functions and consts) if (!empty($metadata['class'])) { $class_name = $metadata['class']; if (isset($all_names[$class_name])) { $this->_throw_js_naming_conflict( $class_name, 'class', $relative_path, $all_names[$class_name]['type'], $all_names[$class_name]['file'] ); } $all_names[$class_name] = ['type' => 'class', 'file' => $relative_path]; } } console_debug('BUNDLE', 'JS naming check passed - no conflicts found'); } /** * Throw exception for JavaScript naming conflict */ protected function _throw_js_naming_conflict(string $name, string $type1, string $file1, string $type2, string $file2): void { $error_message = '=========================================='; $error_message .= "\nFATAL: JavaScript naming conflict in bundle"; $error_message .= "\n=========================================="; $error_message .= "\n\nBundle: {$this->bundle_name}"; $error_message .= "\nName: {$name}"; $error_message .= "\n\nDefined as {$type1} in: {$file1}"; $error_message .= "\nAlso defined as {$type2} in: {$file2}"; $error_message .= "\n\nPROBLEM:"; $error_message .= "\nThe name '{$name}' is used for both a {$type1} and a {$type2}."; $error_message .= "\nJavaScript files in the same bundle share the same scope."; if ($type1 === $type2) { $error_message .= "\nDuplicate {$type1} definitions will conflict."; } else { $error_message .= "\nClasses, functions and const variables share the same namespace."; } $error_message .= "\n\nWHY THIS MATTERS:"; $error_message .= "\n- All files in a bundle are concatenated together"; $error_message .= "\n- Duplicate names overwrite each other or cause errors"; $error_message .= "\n- This leads to unpredictable runtime behavior"; $error_message .= "\n\nFIX OPTIONS:"; $error_message .= "\n1. Rename one of them to be unique"; $error_message .= "\n2. Move into a class as a static member"; $error_message .= "\n3. Remove from bundle if not needed"; $error_message .= "\n4. Use different bundles for conflicting files"; $error_message .= "\n=========================================="; throw new \App\RSpade\Core\Exceptions\YoureDoingItWrongException($error_message); } /** * Get bundle name from class */ protected function _get_bundle_name(string $bundle_class): string { $parts = explode('\\', $bundle_class); return end($parts); } /** * Check if production bundle already exists */ protected function _check_production_cache(): ?array { $bundle_dir = storage_path('rsx-build/bundles'); $js_pattern = "{$bundle_dir}/app.*.js"; $css_pattern = "{$bundle_dir}/app.*.css"; $js_files = glob($js_pattern); $css_files = glob($css_pattern); if (!empty($js_files) || !empty($css_files)) { return [ 'js_bundle_path' => !empty($js_files) ? basename($js_files[0]) : null, 'css_bundle_path' => !empty($css_files) ? basename($css_files[0]) : null, ]; } return null; } /** * Process required bundles (jquery, lodash, jqhtml) */ protected function _process_required_bundles(): void { $required_bundles = config('rsx.required_bundles', []); $bundle_aliases = config('rsx.bundle_aliases', []); foreach ($required_bundles as $alias) { if (isset($bundle_aliases[$alias])) { $this->_process_include_item($bundle_aliases[$alias]); } } } /** * Resolve bundle and all its includes */ protected function _resolve_bundle(string $bundle_class): void { // Get bundle definition if (!method_exists($bundle_class, 'define')) { throw new Exception("Bundle {$bundle_class} missing define() method"); } $definition = $bundle_class::define(); // Process bundle includes if (!empty($definition['include'])) { foreach ($definition['include'] as $item) { $this->_process_include_item($item); } } // Process watch directories if (!empty($definition['watch'])) { foreach ($definition['watch'] as $dir) { $this->_add_watch_directory($dir); } } // Store CDN assets if (!empty($definition['cdn_assets'])) { if (!empty($definition['cdn_assets']['js'])) { $this->cdn_assets['js'] = array_merge($this->cdn_assets['js'], $definition['cdn_assets']['js']); } if (!empty($definition['cdn_assets']['css'])) { $this->cdn_assets['css'] = array_merge($this->cdn_assets['css'], $definition['cdn_assets']['css']); } } // Store NPM includes if (!empty($definition['npm'])) { $this->npm_includes = array_merge($this->npm_includes, $definition['npm']); } // Store config if (!empty($definition['config'])) { $this->config = array_merge($this->config, $definition['config']); } // Process include_routes directive if (!empty($definition['include_routes'])) { foreach ($definition['include_routes'] as $route_path) { // Normalize the path $normalized_path = trim($route_path, '/'); if (!in_array($normalized_path, $this->include_routes)) { $this->include_routes[] = $normalized_path; } } } } /** * Process a single include item */ protected function _process_include_item($item): void { // Create a unique key for this include item $include_key = is_string($item) ? $item : serialize($item); // Skip if already processed if (isset($this->resolved_includes[$include_key])) { return; } $this->resolved_includes[$include_key] = true; // Check bundle aliases and resolve to actual class $bundle_aliases = config('rsx.bundle_aliases', []); if (is_string($item) && isset($bundle_aliases[$item])) { $item = $bundle_aliases[$item]; // Also mark the resolved class as processed if (isset($this->resolved_includes[$item])) { return; } $this->resolved_includes[$item] = true; } // If it's a fully qualified class, resolve as bundle if (is_string($item) && class_exists($item)) { $this->_resolve_bundle($item); return; } // Try to find class by simple name in manifest if (is_string($item) && strpos($item, '/') === false && strpos($item, '.') === false) { try { $manifest = Manifest::get_all(); foreach ($manifest as $file_info) { if (isset($file_info['class']) && $file_info['class'] === $item) { if (isset($file_info['fqcn']) && class_exists($file_info['fqcn'])) { $this->_resolve_bundle($file_info['fqcn']); return; } } } } catch (Exception $e) { // Fall through to file/directory handling } } // Check if item is an absolute path if (str_starts_with($item, '/')) { $base_path = str_replace('\\', '/', base_path()); // Determine project root (parent of system/ if we're in a subdirectory structure) $project_root = $base_path; if (basename($base_path) === 'system') { $project_root = dirname($base_path); } // If it starts with base_path, convert to relative if (str_starts_with($item, $base_path)) { // Convert absolute path to relative path (without leading slash) $item = substr($item, strlen($base_path) + 1); } elseif (str_starts_with($item, $project_root)) { // Path is under project root but not under base_path (e.g., symlinked rsx/) // Convert to relative from base_path $relative_from_project = substr($item, strlen($project_root) + 1); $item = $relative_from_project; } else { throw new RuntimeException( "Bundle include item is an absolute path outside the project directory.\n" . "Path: {$item}\n" . "Project directory: {$project_root}\n" . 'Absolute paths must be within the project directory.' ); } } // Validate that storage directory is not directly included if (is_string($item) && (str_starts_with($item, 'storage/') || str_starts_with($item, '/storage/'))) { throw new RuntimeException( "Direct inclusion of 'storage' directory in bundle is not allowed.\n" . "Path: {$item}\n\n" . 'Auto-generated files (JS stubs, compiled assets) are automatically included ' . 'when their source files are part of the bundle. Include the source files ' . "(e.g., 'rsx/models' for model stubs) rather than the generated output." ); } // Otherwise treat as file/directory path $path = base_path($item); if (is_file($path)) { $this->_add_file($path); return; } if (is_dir($path)) { $this->_add_directory($path); return; } // Try with wildcards $files = glob($path); if (!empty($files)) { foreach ($files as $file) { if (is_file($file)) { $this->_add_file($file); } elseif (is_dir($file)) { $this->_add_directory($file); } } return; } // If we get here, the item could not be resolved throw new RuntimeException("Cannot resolve bundle include item: {$item}. Not found as alias, class, file, or directory."); } /** * Add a file to the bundle */ protected function _add_file(string $path): void { $normalized = rsxrealpath($path); if (!$normalized) { return; } // Deduplicate if (isset($this->included_files[$normalized])) { return; } $this->included_files[$normalized] = true; // Add to main files (will be split into vendor/app later) if (!isset($this->bundle_files['all'])) { $this->bundle_files['all'] = []; } $this->bundle_files['all'][] = $normalized; } /** * Add all files from a directory */ protected function _add_directory(string $path): void { if (!is_dir($path)) { return; } // Get excluded directories from config $excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']); // Create a recursive directory iterator with filtering $directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS); $filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) { // Check if current directory name is in excluded list if ($current->isDir()) { $dirname = $current->getBasename(); return !in_array($dirname, $excluded_dirs); } // Include all files return true; }); $iterator = new RecursiveIteratorIterator( $filter, RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if ($file->isFile()) { $this->_add_file($file->getPathname()); } } } /** * Add a watch directory */ protected function _add_watch_directory(string $dir): void { $path = base_path($dir); if (!is_dir($path)) { return; } // Get excluded directories from config $excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']); // Create a custom filter to exclude configured directories $directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS); $filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) { // Skip excluded directories if ($current->isDir() && in_array($current->getBasename(), $excluded_dirs)) { return false; } return true; }); $iterator = new RecursiveIteratorIterator( $filter, RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { if ($file->isFile()) { $normalized = rsxrealpath($file->getPathname()); if (!isset($this->included_files[$normalized])) { if (!isset($this->watch_files['all'])) { $this->watch_files['all'] = []; } $this->watch_files['all'][] = $normalized; } } } } /** * Split files into vendor and app buckets * * IMPORTANT: Files remain as FLAT ARRAYS at this stage. * We are extension-agnostic throughout processing. * Files could be .php, .blade, .scss, .jqhtml, .xml, .coffee, or ANY future type. * Only at final compilation do we filter by .js and .css extensions. */ protected function _split_vendor_app(): void { $vendor_files = []; $app_files = []; // Split bundle files - flat array, ALL file types mixed together foreach ($this->bundle_files['all'] ?? [] as $file) { if (strpos($file, '/vendor/') !== false) { $vendor_files[] = $file; } else { $app_files[] = $file; } } // Store as flat arrays - NOT organized by extension yet $this->bundle_files = [ 'vendor' => $vendor_files, // Flat array of ALL vendor files 'app' => $app_files, // Flat array of ALL app files ]; // Split watch files - also flat arrays $vendor_watch = []; $app_watch = []; foreach ($this->watch_files['all'] ?? [] as $file) { if (strpos($file, '/vendor/') !== false) { $vendor_watch[] = $file; } else { $app_watch[] = $file; } } $this->watch_files = [ 'vendor' => $vendor_watch, // Flat array of ALL vendor watch files 'app' => $app_watch, // Flat array of ALL app watch files ]; console_debug('BUNDLE', 'Split files - vendor: ' . count($vendor_files) . ', app: ' . count($app_files)); } /** * Check bundle caches and return which need compilation */ protected function _check_bundle_caches(): array { $need_compile = []; $this->cache_keys = []; // Store for use in compile foreach (['vendor', 'app'] as $type) { $cache_key = $this->_get_cache_key($type); $this->cache_keys[$type] = $cache_key; $cached = $this->_get_cached_bundle($cache_key, $type); if (!$cached || !$this->_is_cache_valid($cached, $type)) { $need_compile[] = $type; } } console_debug('BUNDLE', 'Need to compile: ' . implode(', ', $need_compile)); return $need_compile; } /** * Get cache key for bundle type */ protected function _get_cache_key(string $type): string { $files_for_hash = array_merge( $this->bundle_files[$type] ?? [], $this->watch_files[$type] ?? [] ); $hashes = []; foreach ($files_for_hash as $file) { if (!file_exists($file)) { shouldnt_happen("Expected file {$file} does not exist in bundle builder"); } $hash = _rsx_file_hash_for_build($file); $hashes[] = $hash; } $final_key = md5(serialize($hashes)); return $final_key; } /** * Get cached bundle if exists */ protected function _get_cached_bundle(string $cache_key, string $type): ?array { $bundle_dir = storage_path('rsx-build/bundles'); // The cache key is used as part of the filename hash // Look for files with the specific type and hash $short_key = substr($cache_key, 0, 8); $js_file = "{$bundle_dir}/{$this->bundle_name}__{$type}.{$short_key}.js"; $css_file = "{$bundle_dir}/{$this->bundle_name}__{$type}.{$short_key}.css"; $files = []; if (file_exists($js_file)) { $files[] = $js_file; } if (file_exists($css_file)) { $files[] = $css_file; } if (empty($files)) { console_debug('BUNDLE', "No cached files found for {$type} with hash {$short_key}"); return null; } console_debug('BUNDLE', "Found cached files for {$type} with hash {$short_key}"); return ['files' => $files, 'key' => $cache_key]; } /** * Check if cache is still valid */ protected function _is_cache_valid(array $cached, string $type): bool { // In production, if files exist, they're valid if ($this->is_production) { return true; } // Check if all cached files still exist foreach ($cached['files'] as $file) { if (!file_exists($file)) { return false; } } return true; } /** * Process bundles through processors * * IMPORTANT: Files remain as FLAT ARRAYS throughout processing. * Processors can handle ANY file type: .php, .jqhtml, .scss, .xml, .coffee, etc. * We do NOT organize by extension here - only in _compile_outputs. */ protected function _process_bundles(array $types): void { // Check for JavaScript naming conflicts before processing // This only runs when bundles actually need rebuilding $this->_check_js_naming_conflicts($types); $processor_classes = config('rsx.bundle_processors', []); // Sort processors by priority (lower number = higher priority) usort($processor_classes, function ($a, $b) { $priority_a = method_exists($a, 'get_priority') ? $a::get_priority() : 1000; $priority_b = method_exists($b, 'get_priority') ? $b::get_priority() : 1000; return $priority_a - $priority_b; }); foreach ($types as $type) { // Get flat array of ALL files $files = $this->bundle_files[$type] ?? []; // Run each processor with ALL files (flat array, passed by reference) foreach ($processor_classes as $processor_class) { if (!class_exists($processor_class)) { continue; } console_debug('BUNDLE', "Running {$processor_class} on {$type} files (" . count($files) . ' files)'); // Process files - processor modifies array directly by reference if (method_exists($processor_class, 'process_batch')) { $processor_class::process_batch($files); } } // Update the bundle files - STILL AS FLAT ARRAY // Do NOT organize by extension here! $this->bundle_files[$type] = array_values($files); // Re-index array console_debug('BUNDLE', "Processed {$type} - Total files: " . count($files)); } } /** * Write temporary file */ protected function _write_temp_file(string $content, string $extension): string { $path = storage_path('rsx-tmp/bundle_' . uniqid() . '.' . $extension); file_put_contents($path, $content); return $path; } /** * Get JS stubs for all included files in the bundle * * IMPORTANT: ANY file type can have a js_stub - not just PHP files. * Models, controllers, or even custom file types might generate JS stubs. */ protected function _get_js_stubs(): array { $stubs = []; $manifest = Manifest::get_full_manifest(); $manifest_files = $manifest['data']['files'] ?? []; // Get all files from all bundles (app and vendor) $all_files = []; foreach ($this->bundle_files as $type => $files) { // Remember: bundle_files are flat arrays at this point if (is_array($files)) { $all_files = array_merge($all_files, $files); } } // Check each file for JS stubs foreach ($all_files as $file) { $relative = str_replace(base_path() . '/', '', $file); // ANY file can have a js_stub - check manifest for all files if (isset($manifest_files[$relative]['js_stub'])) { $stub_path = base_path() . '/' . $manifest_files[$relative]['js_stub']; if (file_exists($stub_path)) { console_debug('BUNDLE', "Adding JS stub for {$relative}: {$manifest_files[$relative]['js_stub']}"); $stubs[] = $stub_path; } else { console_debug('BUNDLE', "JS stub file not found: {$stub_path}"); } } } console_debug('BUNDLE', 'Found ' . count($stubs) . ' JS stubs total'); return array_unique($stubs); } /** * Order JavaScript files by class dependency * * JavaScript file ordering is critical for proper execution because: * 1. Decorators are executed at class definition time (not invocation) * 2. ES6 class extends clauses are evaluated at definition time * 3. Both require their dependencies to be defined first * * Ordering rules: * 1. Non-class files first (functions, constants, utilities) * 2. Class files ordered by dependency (parent classes and decorator classes first) * 3. Special metadata files (manifest, stubs) - added later by caller * 4. Runner stub - added last by caller * * Dependency types: * - Inheritance: class Foo extends Bar requires Bar to be defined first * - Decorators: @Foo.bar() requires Foo class to be defined first * - Simple decorators: @foobar() requires function foobar to be defined first * * We trust programmers to order non-class files correctly among themselves. * We DO validate and enforce ordering for class dependencies. */ protected function _order_javascript_files_by_dependency(array $js_files): array { console_debug('BUNDLE_SORT', 'Starting dependency sort with ' . count($js_files) . ' files'); $manifest = Manifest::get_full_manifest(); $manifest_files = $manifest['data']['files'] ?? []; // Arrays to hold categorized files $class_files = []; $jqhtml_compiled_files = []; $non_class_files = []; $class_info = []; // Analyze each file for class information foreach ($js_files as $file) { // Check if this is a compiled jqhtml file if (str_contains($file, 'storage/rsx-tmp/jqhtml_')) { $jqhtml_compiled_files[] = $file; continue; } // Skip ALL temp files - they won't be in manifest // Babel and other transformations should have been applied to original files if (str_contains($file, 'storage/rsx-tmp/')) { $non_class_files[] = $file; continue; } // Get file info from manifest $relative = str_replace(base_path() . '/', '', $file); $file_data = $manifest_files[$relative] ?? null; // If file has a class defined, add to class_files if (!empty($file_data['class'])) { $class_files[] = $file; // Build comprehensive class info including decorator dependencies $class_info[$file] = [ 'class' => $file_data['class'], 'extends' => $file_data['extends'] ?? null, 'decorators' => $file_data['decorators'] ?? [], // Extract decorator dependencies from methods 'method_decorators' => $this->_extract_method_decorators($file_data), ]; } else { // No class info - treat as non-class file $non_class_files[] = $file; } } // Order class files by dependency with circular dependency detection $ordered_class_files = $this->_topological_sort_classes($class_files, $class_info); // Find decorator.js and ensure it's the very first file $decorator_file = null; $other_non_class_files = []; foreach ($non_class_files as $file) { // Check if this is the decorator.js file if (str_ends_with($file, '/app/RSpade/Core/Js/decorator.js')) { $decorator_file = $file; } else { $other_non_class_files[] = $file; } } // CRITICAL ORDERING: // 1. decorator.js MUST be first (defines @decorator) // 2. Other non-class files (utilities, decorator functions) // 3. Class files (ordered by dependency) // NOTE: Compiled jqhtml files are returned separately - will be inserted after JS stubs $final_order = []; if ($decorator_file) { $final_order[] = $decorator_file; } $final_order = array_merge($final_order, $other_non_class_files, $ordered_class_files); // Return both the ordered files and the jqhtml files separately // Store jqhtml files in a property for use in _compile_outputs $this->jqhtml_compiled_files = $jqhtml_compiled_files; return $final_order; } /** * Extract decorator dependencies from method metadata * * Scans all methods in the file metadata to find decorator usage. * Decorators like @Foo.bar() create a dependency on class Foo. */ protected function _extract_method_decorators(array $file_data): array { $decorators = []; // Check static methods for decorators if (!empty($file_data['public_static_methods'])) { foreach ($file_data['public_static_methods'] as $method) { if (!empty($method['decorators'])) { foreach ($method['decorators'] as $decorator) { $decorators[] = $decorator; } } } } // Check instance methods for decorators if (!empty($file_data['methods'])) { foreach ($file_data['methods'] as $method) { if (!empty($method['decorators'])) { foreach ($method['decorators'] as $decorator) { $decorators[] = $decorator; } } } } // Check properties for decorators if (!empty($file_data['static_properties'])) { foreach ($file_data['static_properties'] as $property) { if (!empty($property['decorators'])) { foreach ($property['decorators'] as $decorator) { $decorators[] = $decorator; } } } } return $decorators; } /** * Topological sort for class dependencies with decorator support * * This performs a depth-first topological sort to order JavaScript classes * such that all dependencies are defined before the classes that use them. * * Dependency detection: * 1. Inheritance: class Foo extends Bar creates dependency on Bar * 2. Class decorators: @Baz.method() creates dependency on Baz class * 3. Method decorators: Same as class decorators * * Circular dependency handling: * - Detected via temporary visit marking during DFS traversal * - Throws exception with clear error message showing the cycle * - Prevents infinite loops in dependency resolution * * Note: We don't validate if decorator classes actually exist in the bundle. * If @Foo.bar() is used and Foo doesn't exist, that's fine - it might be * defined globally or in a different bundle. We only order known dependencies. */ protected function _topological_sort_classes(array $class_files, array $class_info): array { $sorted = []; $visited = []; $temp_visited = []; $visit_path = []; // Track the current path for circular dependency reporting // Build a map of class names to file paths for quick lookup $class_to_file = []; foreach ($class_info as $file => $info) { if (isset($info['class'])) { $class_to_file[$info['class']] = $file; } } /** * Extract class dependencies from a decorator name * * Examples: * - @Foo.bar() returns 'Foo' * - @Route() returns null (not a class dependency) * - @Namespace.Class.method() returns 'Namespace' (first part only) */ $extract_decorator_class = function ($decorator_name) { // Check if decorator name contains a dot (class.method pattern) if (str_contains($decorator_name, '.')) { // Extract the class name (part before first dot) $parts = explode('.', $decorator_name); // Only return if it starts with uppercase (likely a class) if (!empty($parts[0]) && ctype_upper($parts[0][0])) { return $parts[0]; } } return null; }; /** * Depth-first search for topological sorting * * This recursive function visits each node (file) and its dependencies, * building the sorted list from the bottom up (dependencies first). */ $visit = function ($file) use ( &$visit, &$sorted, &$visited, &$temp_visited, &$visit_path, $class_info, $class_to_file, $extract_decorator_class ) { // Check for circular dependency if (isset($temp_visited[$file])) { // Build the cycle path for error reporting $cycle_start = array_search($file, $visit_path); $cycle = array_slice($visit_path, $cycle_start); $cycle[] = $file; // Complete the circle // Create detailed error message $class_name = $class_info[$file]['class'] ?? 'unknown'; $cycle_description = array_map(function ($f) use ($class_info) { return $class_info[$f]['class'] ?? basename($f); }, $cycle); throw new RuntimeException( "Circular dependency detected in JavaScript class dependencies:\n" . ' ' . implode(' -> ', $cycle_description) . "\n" . " Files involved:\n" . implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', $f), $cycle)) . "\n" . ' Break the circular dependency by refactoring the class relationships.' ); } // Already processed this file if (isset($visited[$file])) { return; } // Mark as temporarily visited and add to path $temp_visited[$file] = true; $visit_path[] = $file; // Collect all dependencies for this file $dependencies = []; // 1. Inheritance dependency: extends clause if (!empty($class_info[$file]['extends'])) { $parent_class = $class_info[$file]['extends']; // Find the file containing the parent class if (isset($class_to_file[$parent_class])) { $parent_file = $class_to_file[$parent_class]; // Only add as dependency if it's a different file if ($parent_file !== $file) { $dependencies[] = $parent_file; } } } // 2. Class decorator dependencies: @Foo.bar() on the class itself // Decorators are in compact format: [[name, [args]], ...] if (!empty($class_info[$file]['decorators'])) { foreach ($class_info[$file]['decorators'] as $decorator) { // Compact format: [name, args] $decorator_name = $decorator[0] ?? null; if (!$decorator_name) { continue; } $decorator_class = $extract_decorator_class($decorator_name); if ($decorator_class && isset($class_to_file[$decorator_class])) { $decorator_file = $class_to_file[$decorator_class]; // Only add as dependency if it's a different file if ($decorator_file !== $file && !in_array($decorator_file, $dependencies)) { $dependencies[] = $decorator_file; } } } } // 3. Method decorator dependencies: decorators on methods/properties if (!empty($class_info[$file]['method_decorators'])) { foreach ($class_info[$file]['method_decorators'] as $decorator) { $decorator_class = $extract_decorator_class($decorator['name']); if ($decorator_class && isset($class_to_file[$decorator_class])) { $decorator_file = $class_to_file[$decorator_class]; // Only add as dependency if it's a different file and not already added if ($decorator_file !== $file && !in_array($decorator_file, $dependencies)) { $dependencies[] = $decorator_file; } } } } // Visit all dependencies first (depth-first) foreach ($dependencies as $dep_file) { $visit($dep_file); } // Remove from temporary visited and path unset($temp_visited[$file]); array_pop($visit_path); // Mark as permanently visited and add to sorted list $visited[$file] = true; $sorted[] = $file; }; // Process all class files foreach ($class_files as $file) { if (!isset($visited[$file])) { $visit($file); //?? } } // try { // (code above was here) // } catch (RuntimeException $e) { // // Re-throw with bundle context if available // if (!empty($this->bundle_class)) { // throw new RuntimeException( // "Bundle compilation failed for {$this->bundle_class}:\n" . $e->getMessage(), // 0, // $e // ); // } // throw $e; // } return $sorted; } /** * Compile final output files * * THIS IS THE ONLY PLACE where we organize files by extension. * Up until now, all files have been in flat arrays regardless of type. * Here we: * 1. Filter to only .js and .css files * 2. Order JS files by class dependency * 3. Add framework code (stubs, manifest, runner) * 4. Generate final compiled output */ protected function _compile_outputs(array $types_to_compile = []): array { $outputs = []; $bundle_dir = storage_path('rsx-build/bundles'); if (!is_dir($bundle_dir)) { mkdir($bundle_dir, 0755, true); } // Only compile types that need it // If empty, nothing needs compiling - just return empty if (empty($types_to_compile)) { return $outputs; } // Compile requested types foreach ($types_to_compile as $type) { // Get the flat array of ALL files for this type $all_files = $this->bundle_files[$type] ?? []; // NOW we finally organize by extension for output // This is the FIRST and ONLY time we care about extensions $files = ['js' => [], 'css' => []]; foreach ($all_files as $file) { $ext = pathinfo($file, PATHINFO_EXTENSION); if ($ext === 'js') { $files['js'][] = $file; } elseif ($ext === 'css') { $files['css'][] = $file; } // Other extensions are ignored - they should have been processed into JS/CSS } // Order JavaScript files by class dependency BEFORE adding framework code if (!empty($files['js'])) { $files['js'] = $this->_order_javascript_files_by_dependency($files['js']); } // Use the cache key hash for filenames $hash = isset($this->cache_keys[$type]) ? substr($this->cache_keys[$type], 0, 8) : substr(md5(uniqid()), 0, 8); // Clean old files for this type only $old_files = glob("{$bundle_dir}/{$this->bundle_name}__{$type}.*"); foreach ($old_files as $file) { // Don't delete files with current hash if (strpos($file, ".{$hash}.") === false) { unlink($file); } } // Add NPM includes to vendor JS if ($type === 'vendor' && !empty($this->npm_includes)) { $npm_bundle_path = $this->_generate_npm_includes(); if ($npm_bundle_path) { // Add the NPM bundle file path directly array_unshift($files['js'], $npm_bundle_path); } } // Add JS stubs and framework code to app JS if ($type === 'app') { // Add NPM import declarations at the very beginning if (!empty($this->npm_includes)) { $npm_import_file = $this->_generate_npm_import_declarations(); if ($npm_import_file) { array_unshift($files['js'], $npm_import_file); } } // ALWAYS get JS stubs for ALL included files that have them // ANY file type can have a js_stub - controllers, models, custom types, etc. $stub_files = $this->_get_js_stubs(); foreach ($stub_files as $stub) { $files['js'][] = $stub; } // Add compiled jqhtml files AFTER JS stubs // These are JavaScript files generated from .jqhtml templates foreach ($this->jqhtml_compiled_files as $jqhtml_file) { $files['js'][] = $jqhtml_file; } // Generate manifest definitions for all JS classes $manifest_file = $this->_create_javascript_manifest($files['js'] ?? []); if ($manifest_file) { $files['js'][] = $manifest_file; } // Generate route definitions for JavaScript $route_file = $this->_create_javascript_routes(); if ($route_file) { $files['js'][] = $route_file; } // Generate initialization runner $runner_file = $this->_create_javascript_runner(); if ($runner_file) { $files['js'][] = $runner_file; } } // Compile JS if (!empty($files['js'])) { $js_content = $this->_compile_js_files($files['js']); $js_file = "{$this->bundle_name}__{$type}.{$hash}.js"; file_put_contents("{$bundle_dir}/{$js_file}", $js_content); $outputs["{$type}_js"] = $js_file; } // Compile CSS if (!empty($files['css'])) { $css_content = $this->_compile_css_files($files['css']); $css_file = "{$this->bundle_name}__{$type}.{$hash}.css"; file_put_contents("{$bundle_dir}/{$css_file}", $css_content); $outputs["{$type}_css"] = $css_file; } } return $outputs; } /** * Parse an import statement string into components * * Examples: * "import { Component } from '@jqhtml/core'" * "import * as React from 'react'" * "import lodash from 'lodash'" * * @throws RuntimeException if statement is invalid */ protected function _parse_import_statement(string $statement): array { $statement = trim($statement); // Must start with "import " and end with single or double quote if (!str_starts_with($statement, 'import ')) { throw new RuntimeException("Invalid import statement - must start with 'import ': {$statement}"); } if (!str_ends_with($statement, "'") && !str_ends_with($statement, '"')) { throw new RuntimeException("Invalid import statement - must end with quote: {$statement}"); } // Extract the package name (everything between quotes) if (preg_match("/from\\s+['\"]([^'\"]+)['\"]\\s*$/", $statement, $matches)) { $package_name = $matches[1]; } else { throw new RuntimeException("Invalid import statement - cannot extract package: {$statement}"); } // Extract what's being imported (between "import" and "from") $import_part = trim(substr($statement, 7)); // Remove "import " $from_pos = strrpos($import_part, ' from '); if ($from_pos === false) { throw new RuntimeException("Invalid import statement - missing 'from': {$statement}"); } $import_spec = trim(substr($import_part, 0, $from_pos)); // Determine the import type and extract names if (str_starts_with($import_spec, '{') && str_ends_with($import_spec, '}')) { // Named imports: { Component, Something } $type = 'named'; $names = trim(substr($import_spec, 1, -1)); } elseif (str_starts_with($import_spec, '* as ')) { // Namespace import: * as Something $type = 'namespace'; $names = trim(substr($import_spec, 5)); } else { // Default import: Something $type = 'default'; $names = $import_spec; } return [ 'statement' => $statement, 'package' => $package_name, 'type' => $type, 'imports' => $names, ]; } /** * Generate NPM includes using esbuild compilation * Returns the path to the generated bundle file, not the content */ protected function _generate_npm_includes(): string { if (empty($this->npm_includes)) { return ''; } // Parse all import statements $parsed_imports = []; foreach ($this->npm_includes as $global_name => $import_statement) { try { $parsed = $this->_parse_import_statement($import_statement); $parsed['global_name'] = $global_name; $parsed_imports[] = $parsed; } catch (RuntimeException $e) { throw new RuntimeException( "Invalid NPM import for '{$global_name}': " . $e->getMessage() ); } } // Collect all package-lock.json files for cache key $package_lock_contents = []; $lock_files = [ 'package-lock.json', 'internal-libs/jqhtml/package-lock.json', 'internal-libs/rsx-scss-lint/package-lock.json', ]; foreach ($lock_files as $lock_file) { $full_path = base_path($lock_file); if (file_exists($full_path)) { $package_lock_contents[$lock_file] = md5_file($full_path); } } // Generate cache key from: // 1. All package-lock.json file hashes // 2. The npm array itself (sorted for consistency) // 3. The current working directory $cache_components = [ 'package_locks' => $package_lock_contents, 'npm_array' => $this->npm_includes, 'cwd' => getcwd(), ]; $cache_key = md5(serialize($cache_components)); // Generate bundle filename for this specific bundle $bundle_filename = "npm_{$this->bundle_name}_{$cache_key}.js"; $bundle_path = storage_path("rsx-build/bundles/{$bundle_filename}"); // Check if bundle already exists if (file_exists($bundle_path)) { console_debug('BUNDLE', "Using cached NPM bundle: {$bundle_filename}"); return $bundle_path; } console_debug('BUNDLE', "Compiling NPM modules for {$this->bundle_name}"); // Create storage directory if needed $bundle_dir = dirname($bundle_path); if (!is_dir($bundle_dir)) { mkdir($bundle_dir, 0755, true); } // Generate entry file content with import statements $entry_content = "// Auto-generated NPM module exports for {$this->bundle_name}\n"; $entry_content .= "// Cache key: {$cache_key}\n\n"; // Initialize the _rsx_npm object $entry_content .= "// Initialize RSX NPM module container\n"; $entry_content .= "window._rsx_npm = window._rsx_npm || {};\n\n"; foreach ($parsed_imports as $import) { $global_name = $import['global_name']; $statement = $import['statement']; // Add the original import statement $entry_content .= "{$statement}\n"; // Add window._rsx_npm assignment based on import type switch ($import['type']) { case 'named': // For named imports, handle single vs multiple differently $names = explode(',', $import['imports']); $exports = []; foreach ($names as $name) { $name = trim($name); // Handle "name as alias" syntax if (str_contains($name, ' as ')) { [$original, $alias] = explode(' as ', $name); $exports[] = trim($alias); } else { $exports[] = $name; } } // If there's only one named import, assign it directly (for class constructors) // Otherwise create an object with the named exports if (count($exports) === 1) { $entry_content .= "window._rsx_npm.{$global_name} = {$exports[0]};\n"; } else { $entry_content .= "window._rsx_npm.{$global_name} = { " . implode(', ', $exports) . " };\n"; } break; case 'namespace': // For namespace imports, assign directly $import_name = $import['imports']; $entry_content .= "window._rsx_npm.{$global_name} = {$import_name};\n"; break; case 'default': // For default imports, assign directly $import_name = $import['imports']; $entry_content .= "window._rsx_npm.{$global_name} = {$import_name};\n"; break; } } // Write entry file to temp location $temp_dir = storage_path('rsx-tmp/npm-compile'); if (!is_dir($temp_dir)) { mkdir($temp_dir, 0755, true); } $entry_file = "{$temp_dir}/entry_{$cache_key}.js"; file_put_contents($entry_file, $entry_content); // Use esbuild for bundling $esbuild_bin = base_path('node_modules/.bin/esbuild'); if (!file_exists($esbuild_bin)) { throw new RuntimeException('esbuild not found. Please run: npm install --save-dev esbuild'); } // Set working directory to project root for proper module resolution $original_cwd = getcwd(); chdir(base_path()); $esbuild_cmd = sprintf( '%s %s --bundle --format=iife --target=es2020 --outfile=%s 2>&1', escapeshellarg($esbuild_bin), escapeshellarg($entry_file), escapeshellarg($bundle_path) ); $output = []; $return_var = 0; \exec_safe($esbuild_cmd, $output, $return_var); // Restore working directory chdir($original_cwd); if ($return_var !== 0) { console_debug('BUNDLE', 'ESBuild failed: ' . implode("\n", $output)); // Clean up temp file on error @unlink($entry_file); throw new RuntimeException('Failed to compile NPM modules: ' . implode("\n", $output)); } // Clean up temp file @unlink($entry_file); // Verify bundle was created if (!file_exists($bundle_path)) { throw new RuntimeException("ESBuild did not produce expected output file: {$bundle_path}"); } $size = filesize($bundle_path); console_debug('BUNDLE', "NPM bundle compiled: {$bundle_filename} ({$size} bytes)"); return $bundle_path; } /** * Generate NPM import declarations file for app bundle * This creates const declarations that pull from window._rsx_npm */ protected function _generate_npm_import_declarations(): string { if (empty($this->npm_includes)) { return ''; } // Generate cache key from NPM includes $cache_key = md5(serialize($this->npm_includes)); $filename = "npm_import_declarations_{$cache_key}.js"; $file_path = storage_path("rsx-tmp/{$filename}"); // Check if already generated if (file_exists($file_path)) { return $file_path; } // Generate content $content = "// NPM Import Declarations for App Bundle\n"; $content .= "// Auto-generated to provide NPM modules to app bundle scope\n"; $content .= "// Cache key: {$cache_key}\n\n"; foreach ($this->npm_includes as $global_name => $import_statement) { // Generate const declaration $content .= "const {$global_name} = window._rsx_npm.{$global_name};\n"; $content .= "if (!{$global_name}) {\n"; $content .= " throw new Error(\n"; $content .= " 'RSX Framework Error: NPM module \"{$global_name}\" not found.\\n' +\n"; $content .= " 'Expected window._rsx_npm.{$global_name} to be defined by the vendor bundle.'\n"; $content .= " );\n"; $content .= "}\n\n"; } // Clean up window._rsx_npm to prevent console access $content .= "// Clean up NPM container to prevent console access\n"; $content .= "delete window._rsx_npm;\n"; // Write file file_put_contents($file_path, $content); return $file_path; } /** * Compile JS files */ protected function _compile_js_files(array $files): string { // If we have config, write it to a temp file first $files_to_concat = []; if (!empty($this->config)) { $config_content = ['window.rsxapp = window.rsxapp || {};']; foreach ($this->config as $key => $value) { $config_content[] = "window.rsxapp.{$key} = " . json_encode($value) . ';'; } // Write config to temp file $config_file = storage_path('rsx-tmp/bundle_config_' . uniqid() . '.js'); file_put_contents($config_file, implode("\n", $config_content) . "\n"); $files_to_concat[] = $config_file; } // Transform JavaScript files if Babel is enabled and decorators are used $babel_enabled = config('rsx.javascript.babel.transform_enabled', true); $decorators_enabled = config('rsx.javascript.decorators', true); if ($babel_enabled && $decorators_enabled) { // Use the JavaScript Transformer to transpile files with decorators // IMPORTANT: We populate $babel_file_mapping but DON'T modify $files array // This preserves dependency sort order - we substitute babel versions during concat foreach ($files as $file) { // Skip temp files and already processed files if (str_contains($file, 'storage/rsx-tmp/') || str_contains($file, 'storage/rsx-build/')) { continue; } // Transform the file (will use cache if available) try { $transformed_code = \App\RSpade\Core\JavaScript\Js_Transformer::transform($file); // Write transformed code to a temp file $temp_file = storage_path('rsx-tmp/babel_' . md5($file) . '_' . uniqid() . '.js'); file_put_contents($temp_file, $transformed_code); // Store mapping: original file => babel file // During concatenation we'll use the babel version $this->babel_file_mapping[$file] = $temp_file; console_debug('BUNDLE', 'Transformed ' . str_replace(base_path() . '/', '', $file)); } catch (Exception $e) { // FAIL LOUD - Never allow untransformed decorators through throw new RuntimeException( 'JavaScript transformation failed for ' . str_replace(base_path() . '/', '', $file) . "\nDecorators require Babel transformation to work in browsers.\n" . 'Error: ' . $e->getMessage() . "\n" . 'Fix: cd ' . base_path('app/RSpade/Core/JavaScript/resource') . ' && npm install' ); } } } // Add all the JS files $files_to_concat = array_merge($files_to_concat, $files); // Use Node.js script to concatenate with source map support $output_file = storage_path('rsx-tmp/bundle_output_' . uniqid() . '.js'); $concat_script = base_path('app/RSpade/Core/Bundle/resource/concat-js.js'); // Build the command $cmd_parts = [ 'node', escapeshellarg($concat_script), escapeshellarg($output_file), ]; foreach ($files_to_concat as $file) { // Use babel-transformed version if it exists, otherwise use original $file_to_use = $this->babel_file_mapping[$file] ?? $file; $cmd_parts[] = escapeshellarg($file_to_use); } $cmd = implode(' ', $cmd_parts); // Execute the concatenation $output = []; $return_var = 0; \exec_safe($cmd . ' 2>&1', $output, $return_var); if ($return_var !== 0) { $error_msg = implode("\n", $output); throw new RuntimeException('Failed to concatenate JavaScript files: ' . $error_msg); } // Log the concatenation output if (!empty($output)) { foreach ($output as $line) { console_debug('BUNDLE', "concat-js: {$line}"); } } // Read the concatenated result if (!file_exists($output_file)) { throw new RuntimeException("Concatenation script did not produce output file: {$output_file}"); } $js = file_get_contents($output_file); // Clean up temp files if (!empty($this->config) && isset($config_file) && file_exists($config_file)) { @unlink($config_file); } @unlink($output_file); // Minify in production (TODO: preserve source maps when minifying) if ($this->is_production) { // TODO: Add minification that preserves source maps } return $js; } /** * Compile CSS files with sourcemap support */ protected function _compile_css_files(array $files): string { // Use Node.js script to concatenate with source map support (mirrors JS approach) $output_file = storage_path('rsx-tmp/css_bundle_' . uniqid() . '.css'); $concat_script = base_path('app/RSpade/Core/Bundle/resource/concat-css.js'); // Build the command $cmd_parts = [ 'node', escapeshellarg($concat_script), escapeshellarg($output_file), ]; foreach ($files as $file) { $cmd_parts[] = escapeshellarg($file); } $cmd = implode(' ', $cmd_parts); // Execute the concatenation $output = []; $return_var = 0; \exec_safe($cmd . ' 2>&1', $output, $return_var); if ($return_var !== 0) { $error_msg = implode("\n", $output); throw new RuntimeException('Failed to concatenate CSS files: ' . $error_msg); } // Log the concatenation output if (!empty($output)) { foreach ($output as $line) { console_debug('BUNDLE', "concat-css: {$line}"); } } // Read the concatenated result if (!file_exists($output_file)) { throw new RuntimeException("CSS concatenation script did not produce output file: {$output_file}"); } $css = file_get_contents($output_file); // Clean up temp file @unlink($output_file); // Minify in production if ($this->is_production) { // TODO: Add minification that preserves source maps } return $css; } /** * Create JavaScript manifest definitions */ protected function _create_javascript_manifest(array $js_files): ?string { $class_definitions = []; $manifest_files = Manifest::get_all(); // Analyze each JavaScript file for class information foreach ($js_files as $file) { // Skip temp files if (str_contains($file, 'storage/rsx-tmp/')) { continue; } // Get relative path for manifest lookup $relative = str_replace(base_path() . '/', '', $file); // Get file data from manifest $file_data = $manifest_files[$relative] ?? null; // If we have class information from manifest, use it if (!empty($file_data['class'])) { $class_name = $file_data['class']; $extends_class = $file_data['extends'] ?? null; // Skip if extends is same as class (manifest parsing error) if ($extends_class === $class_name) { $extends_class = null; } $class_definitions[$class_name] = [ 'name' => $class_name, 'extends' => $extends_class, 'decorators' => $file_data['method_decorators'] ?? null, ]; } elseif (file_exists($file)) { // Parse the file directly for classes $content = file_get_contents($file); $classes = $this->_parse_javascript_classes($content); foreach ($classes as $class_info) { $class_definitions[$class_info['name']] = [ 'name' => $class_info['name'], 'extends' => $class_info['extends'], 'decorators' => null, // No decorator info when parsing directly ]; } } } // If no classes found, return null if (empty($class_definitions)) { return null; } // Generate JavaScript code for manifest $js_items = []; foreach ($class_definitions as $class_def) { $class_name = $class_def['name']; $extends_class = $class_def['extends']; $decorators = $class_def['decorators'] ?? null; // Skip 'object' as it's not a real class if (strtolower($class_name) === 'object') { continue; } // Build the manifest entry parts $parts = []; $parts[] = $class_name; // Class object $parts[] = "\"{$class_name}\""; // Class name string $parts[] = $extends_class ?: 'null'; // Parent class or null // Add decorator data if present if (!empty($decorators)) { $decorator_json = json_encode($decorators, JSON_UNESCAPED_SLASHES); $parts[] = $decorator_json; } // Generate the array entry $js_items[] = '[' . implode(', ', $parts) . ']'; } // Build the JavaScript code $js_code = "// JavaScript Manifest - Generated by BundleCompiler\n"; $js_code .= "// Registers all classes in this bundle for runtime introspection\n"; $js_code .= "Manifest._define([\n"; $js_code .= ' ' . implode(",\n ", $js_items) . "\n"; $js_code .= "]);\n\n"; // Write to temporary file return $this->_write_temp_file($js_code, 'js'); } /** * Parse JavaScript content to extract class definitions */ protected function _parse_javascript_classes(string $content): array { $classes = []; // Match ES6 class declarations with optional extends $pattern = '/(?:^|\s)class\s+([A-Z]\w*)(?:\s+extends\s+([A-Z]\w*))?/m'; if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $classes[] = [ 'name' => $match[1], 'extends' => isset($match[2]) ? $match[2] : null, ]; } } return $classes; } /** * Create JavaScript runner for automatic class initialization */ protected function _create_javascript_runner(): string { $js_code = <<<'JS' $(document).ready(async function() { try { console_debug('RSX_INIT', 'Document ready, starting Rsx._rsx_core_boot'); await Rsx._rsx_core_boot(); console_debug('RSX_INIT', 'Initialization complete'); } catch (error) { console.error('[RSX_INIT] Initialization failed:', error); console.error('[RSX_INIT] Stack:', error.stack); throw error; } }); JS; // Write to temporary file return $this->_write_temp_file($js_code, 'js'); } /** * Create JavaScript route definitions from PHP controllers */ protected function _create_javascript_routes(): ?string { // Use Manifest::get_all() which returns the proper file metadata $manifest_files = Manifest::get_all(); $routes = []; console_debug('BUNDLE', 'Scanning for routes in ' . count($this->included_files) . ' included files'); // Scan all included files for controllers foreach ($this->included_files as $file_path => $included) { // Only check PHP files if (!str_ends_with($file_path, '.php')) { continue; } // Get relative path for manifest lookup $relative = str_replace(base_path() . '/', '', $file_path); // Check if file is in manifest if (!isset($manifest_files[$relative])) { continue; } $file_info = $manifest_files[$relative]; // Skip if no class in file if (empty($file_info['fqcn'])) { continue; } $class_name = $file_info['class']; $fqcn = $file_info['fqcn']; // Check if it extends Rsx_Controller_Abstract if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) { continue; } console_debug('BUNDLE', "Found controller: {$class_name}"); // Process methods with Route attributes foreach ($file_info['methods'] ?? [] as $method_name => $method_data) { foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) { if ($attr_name === 'Route') { // Get the first route pattern (index 0 is the first argument) foreach ($attr_instances as $instance) { $route_pattern = $instance[0] ?? null; if ($route_pattern) { console_debug('BUNDLE', " Found route: {$class_name}::{$method_name} => {$route_pattern}"); // Store route info if (!isset($routes[$class_name])) { $routes[$class_name] = []; } $routes[$class_name][$method_name] = $route_pattern; } } } } } } // Also scan include_routes paths if specified foreach ($this->include_routes as $route_path) { $full_path = base_path($route_path); if (!is_dir($full_path)) { continue; } // Scan directory for PHP files $php_files = $this->_scan_directory_recursive($full_path, 'php'); foreach ($php_files as $file) { // Get relative path for manifest lookup $relative = str_replace(base_path() . '/', '', $file); // Check if file is in manifest if (!isset($manifest_files[$relative])) { continue; } $file_info = $manifest_files[$relative]; // Skip if no class in file if (empty($file_info['fqcn'])) { continue; } $class_name = $file_info['class']; $fqcn = $file_info['fqcn']; // Check if it extends Rsx_Controller_Abstract if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) { continue; } // Process methods with Route attributes foreach ($file_info['methods'] ?? [] as $method_name => $method_data) { foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) { if ($attr_name === 'Route') { // Get the first route pattern (index 0 is the first argument) foreach ($attr_instances as $instance) { $route_pattern = $instance[0] ?? null; if ($route_pattern) { // Store route info if (!isset($routes[$class_name])) { $routes[$class_name] = []; } $routes[$class_name][$method_name] = $route_pattern; } } } } } } } // If no routes found, return null if (empty($routes)) { return null; } // Generate JavaScript code $js_code = "// RSX Route Definitions - Generated by BundleCompiler\n"; $js_code .= "// Provides route patterns for type-safe URL generation\n"; $js_code .= 'Rsx._define_routes(' . json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . ");\n"; // Write to temporary file return $this->_write_temp_file($js_code, 'js'); } /** * Recursively scan directory for files with specific extension */ protected function _scan_directory_recursive(string $path, string $extension): array { $files = []; if (!is_dir($path)) { return $files; } $directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS); $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); foreach ($iterator as $file) { if ($file->isFile() && $file->getExtension() === $extension) { $files[] = $file->getPathname(); } } return $files; } }