[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' => []]; /** * Public directory assets (served via AssetHandler with filemtime cache-busting) * Format: ['js' => [['url' => '/path/to/file.js', 'full_path' => '/full/filesystem/path']], ...] */ protected array $public_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 = []; /** * The root module bundle class being compiled * Used for validation error messages */ protected string $root_bundle_class = ''; /** * 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 = []; /** * Bundle build lock token (prevents parallel RPC server interference) */ protected ?string $bundle_build_lock = null; /** * Compile a bundle */ public function compile(string $bundle_class, array $options = []): array { $this->bundle_name = $this->_get_bundle_name($bundle_class); $this->is_production = Rsx_Mode::is_production_like(); $force_build = $options['force_build'] ?? false; console_debug('BUNDLE', "Compiling {$this->bundle_name} (mode: " . Rsx_Mode::get() . ')'); // Step 1: In production-like modes, require pre-built bundles (unless force_build) if ($this->is_production && !$force_build) { $existing = $this->_check_production_cache(); if ($existing) { console_debug('BUNDLE', 'Using existing production bundle'); return $existing; } // In production-like modes, don't auto-rebuild - error instead throw new RuntimeException( "Bundle '{$this->bundle_name}' not compiled for production mode. " . 'Run: php artisan rsx:prod:build' ); } // Step 2: Mark the bundle we're compiling as already resolved $this->resolved_includes[$bundle_class] = true; $this->root_bundle_class = $bundle_class; // 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 (or force all if force_build) if ($force_build) { $need_compile = ['vendor', 'app']; $this->cache_keys = []; foreach ($need_compile as $type) { $this->cache_keys[$type] = $this->_get_cache_key($type); } console_debug('BUNDLE', 'Force build - compiling all types'); } else { $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', 'Acquiring bundle build lock'); // Get a bundle build lock to prevent parallel RPC server interference $this->bundle_build_lock = RsxLocks::get_lock( RsxLocks::SERVER_LOCK, RsxLocks::LOCK_BUNDLE_BUILD, RsxLocks::WRITE_LOCK, config('rsx.locking.timeout', 30) ); console_debug('BUNDLE', 'Bundle build lock acquired, double-checking cache'); // Double-check cache after acquiring lock (race condition protection) // Maybe another process just finished compiling while we were waiting $need_compile = $this->_check_bundle_caches(); if (!empty($need_compile)) { console_debug('BUNDLE', 'Processing bundles: ' . json_encode($need_compile)); $this->_process_bundles($need_compile); } else { console_debug('BUNDLE', 'Cache updated by another process, using cache'); } } 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 $result = []; // CDN assets are always output as separate tags // In development mode: loaded directly from CDN URLs // In production-like modes: served from /_vendor/ (cached locally) if (!empty($this->cdn_assets['js'])) { $result['cdn_js'] = $this->_prepare_cdn_assets($this->cdn_assets['js'], 'js'); } if (!empty($this->cdn_assets['css'])) { $result['cdn_css'] = $this->_prepare_cdn_assets($this->cdn_assets['css'], 'css'); } // Add public directory assets if (!empty($this->public_assets['js'])) { $result['public_js'] = $this->public_assets['js']; } if (!empty($this->public_assets['css'])) { $result['public_css'] = $this->public_assets['css']; } // Always return vendor/app split paths 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']; } // 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); } /** * Get bundle FQCN from simple class name */ protected function _get_bundle_fqcn(string $bundle_name): string { // If already a FQCN, return as-is if (str_contains($bundle_name, '\\')) { return $bundle_name; } // Look up in Manifest $metadata = Manifest::php_get_metadata_by_class($bundle_name); return $metadata['fqcn']; } /** * Check if production bundle already exists */ protected function _check_production_cache(): ?array { $bundle_dir = storage_path('rsx-build/bundles'); // Look for split vendor/app files (current output format) // Future: support merged files when Rsx_Mode::should_merge_bundles() $vendor_js_pattern = "{$bundle_dir}/{$this->bundle_name}__vendor.*.js"; $app_js_pattern = "{$bundle_dir}/{$this->bundle_name}__app.*.js"; $vendor_css_pattern = "{$bundle_dir}/{$this->bundle_name}__vendor.*.css"; $app_css_pattern = "{$bundle_dir}/{$this->bundle_name}__app.*.css"; $vendor_js_files = glob($vendor_js_pattern); $app_js_files = glob($app_js_pattern); $vendor_css_files = glob($vendor_css_pattern); $app_css_files = glob($app_css_pattern); // Need at least one app file (JS is typically required) if (!empty($app_js_files)) { $result = [ 'vendor_js_bundle_path' => !empty($vendor_js_files) ? basename($vendor_js_files[0]) : null, 'app_js_bundle_path' => !empty($app_js_files) ? basename($app_js_files[0]) : null, 'vendor_css_bundle_path' => !empty($vendor_css_files) ? basename($vendor_css_files[0]) : null, 'app_css_bundle_path' => !empty($app_css_files) ? basename($app_css_files[0]) : null, ]; // Also resolve CDN assets - they're served separately via /_vendor/ URLs // We need to resolve the bundle includes to get CDN asset definitions $this->_resolve_cdn_assets_only(); if (!empty($this->cdn_assets['js'])) { $result['cdn_js'] = $this->_prepare_cdn_assets($this->cdn_assets['js'], 'js'); } if (!empty($this->cdn_assets['css'])) { $result['cdn_css'] = $this->_prepare_cdn_assets($this->cdn_assets['css'], 'css'); } return $result; } return null; } /** * Resolve only CDN assets without full bundle compilation * * Used when serving from production cache to get CDN asset URLs * without re-resolving and re-compiling all bundle files. */ protected function _resolve_cdn_assets_only(): void { // Process required bundles first (they may have CDN assets) $required_bundles = config('rsx.required_bundles', []); $bundle_aliases = config('rsx.bundle_aliases', []); foreach ($required_bundles as $alias) { if (isset($bundle_aliases[$alias])) { $this->_collect_cdn_assets_from_include($bundle_aliases[$alias]); } } // Get the bundle's own CDN assets $fqcn = $this->_get_bundle_fqcn($this->bundle_name); $definition = $fqcn::define(); // Add CDN assets from the bundle definition (same format as main resolution) 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']); } } // Also check for Asset Bundle includes that may have CDN assets foreach ($definition['include'] ?? [] as $include) { $this->_collect_cdn_assets_from_include($include); } } /** * Collect CDN assets from a bundle include without full resolution */ protected function _collect_cdn_assets_from_include($include): void { // Handle config array format (from bundle aliases like jquery, lodash) if (is_array($include) && isset($include['cdn'])) { foreach ($include['cdn'] as $cdn_item) { // Determine type from URL extension $url = $cdn_item['url'] ?? ''; $type = str_ends_with($url, '.css') ? 'css' : 'js'; $this->cdn_assets[$type][] = $cdn_item; } return; } // Handle class name includes (could be Asset Bundles with CDN assets) // Check manifest data directly since php_find_class throws if not found if (is_string($include) && isset(Manifest::$data['data']['php_classes'][$include])) { if (Manifest::php_is_subclass_of($include, 'Rsx_Asset_Bundle_Abstract')) { $asset_def = $include::define(); if (!empty($asset_def['cdn_assets'])) { if (!empty($asset_def['cdn_assets']['js'])) { $this->cdn_assets['js'] = array_merge($this->cdn_assets['js'], $asset_def['cdn_assets']['js']); } if (!empty($asset_def['cdn_assets']['css'])) { $this->cdn_assets['css'] = array_merge($this->cdn_assets['css'], $asset_def['cdn_assets']['css']); } } } } } /** * 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]); } } // Include custom JS model base class if configured // This allows users to define application-wide model functionality $js_model_base_class = config('rsx.js_model_base_class'); if ($js_model_base_class) { $this->_include_js_model_base_class($js_model_base_class); } } /** * Include the custom JS model base class file in the bundle * * Finds the JS file by class name in the manifest and adds it to the bundle. * Validates that the class extends Rsx_Js_Model. */ protected function _include_js_model_base_class(string $class_name): void { // Find the JS file in the manifest by class name try { $file_path = Manifest::js_find_class($class_name); } catch (\RuntimeException $e) { throw new \RuntimeException( "JavaScript model base class '{$class_name}' configured in rsx.js_model_base_class not found in manifest.\n" . "Ensure the class is defined in a .js file within your application (e.g., rsx/lib/{$class_name}.js)" ); } // Get metadata to verify it extends Rsx_Js_Model $metadata = Manifest::get_file($file_path); $extends = $metadata['extends'] ?? null; if ($extends !== 'Rsx_Js_Model') { throw new \RuntimeException( "JavaScript model base class '{$class_name}' must extend Rsx_Js_Model.\n" . "Found: extends {$extends}\n" . "File: {$file_path}" ); } // Add the file to the bundle by processing it as a path $this->_process_include_item($file_path); } /** * Resolve bundle and all its includes * * @param string $bundle_class The bundle class to resolve * @param bool $discovered_via_scan Whether this bundle was discovered via directory scan */ protected function _resolve_bundle(string $bundle_class, bool $discovered_via_scan = false): void { // Get bundle definition if (!method_exists($bundle_class, 'define')) { throw new Exception("Bundle {$bundle_class} missing define() method"); } // Validate module bundle doesn't include another module bundle if (Manifest::php_is_subclass_of($bundle_class, 'Rsx_Module_Bundle_Abstract') && $bundle_class !== $this->root_bundle_class) { Rsx_Module_Bundle_Abstract::validate_include($bundle_class, $this->root_bundle_class); } // Validate asset bundles discovered via scan don't have directory paths if ($discovered_via_scan && Manifest::php_is_subclass_of($bundle_class, 'Rsx_Asset_Bundle_Abstract')) { Rsx_Asset_Bundle_Abstract::validate_no_directory_scanning($bundle_class, $this->root_bundle_class); } $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 for /public/ prefix - static assets from public directories if (is_string($item) && str_starts_with($item, '/public/')) { $relative_path = substr($item, 8); // Strip '/public/' prefix try { // Resolve via AssetHandler (with Redis caching) $full_path = \App\RSpade\Core\Dispatch\AssetHandler::find_public_asset($relative_path); // Determine file type $extension = strtolower(pathinfo($relative_path, PATHINFO_EXTENSION)); if ($extension === 'js') { $this->public_assets['js'][] = [ 'url' => '/' . $relative_path, 'full_path' => $full_path, ]; } elseif ($extension === 'css') { $this->public_assets['css'][] = [ 'url' => '/' . $relative_path, 'full_path' => $full_path, ]; } else { throw new RuntimeException( "Public asset must be .js or .css file: {$item}\n" . 'Only JavaScript and CSS files can be included via /public/ prefix.' ); } return; } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { throw new RuntimeException( "Failed to resolve public asset: {$item}\n" . $e->getMessage() ); } } // 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." ); } // Validate path format (development mode only) if (!app()->environment('production') && is_string($item)) { if (str_starts_with($item, 'system/app/') || str_starts_with($item, '/system/app/')) { throw new RuntimeException( "'system/app/' is not a valid RSpade path. Use 'app/' instead.\n" . "Invalid path: {$item}\n" . 'Correct path: ' . str_replace('system/app/', 'app/', $item) . "\n\n" . 'From the bundle perspective, framework code is at app/, not system/app/' ); } if (str_starts_with($item, 'system/rsx/') || str_starts_with($item, '/system/rsx/')) { throw new RuntimeException( "'system/rsx/' is not a valid RSpade path. Use 'rsx/' instead.\n" . "Invalid path: {$item}\n" . 'Correct path: ' . str_replace('system/rsx/', 'rsx/', $item) . "\n\n" . 'From the bundle perspective, application code is at rsx/, not system/rsx/' ); } } // 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 * * Also auto-discovers Asset Bundles in the directory and processes them. */ 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']); // Track discovered asset bundles to process after file collection $discovered_bundles = []; // 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()) { $filepath = $file->getPathname(); $extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION)); // For PHP files, check if it's an asset bundle via manifest if ($extension === 'php') { $relative_path = str_replace(base_path() . '/', '', $filepath); // Get file metadata from manifest to check if it's an asset bundle try { $file_meta = Manifest::get_file($relative_path); $class_name = $file_meta['class'] ?? null; // Use manifest to check if this PHP class is an asset bundle if ($class_name && Manifest::php_is_subclass_of($class_name, 'Rsx_Asset_Bundle_Abstract')) { $fqcn = $file_meta['fqcn'] ?? null; if ($fqcn && !isset($this->resolved_includes[$fqcn])) { $discovered_bundles[] = $fqcn; console_debug('BUNDLE', "Auto-discovered asset bundle: {$fqcn}"); } // Don't add bundle file itself to file list - we'll process it as a bundle continue; } } catch (RuntimeException $e) { // File not in manifest, just add it normally } } $this->_add_file($filepath); } } // Process discovered asset bundles (marked as discovered via scan) foreach ($discovered_bundles as $bundle_fqcn) { if (!isset($this->resolved_includes[$bundle_fqcn])) { $this->resolved_includes[$bundle_fqcn] = true; $this->_resolve_bundle($bundle_fqcn, true); // true = discovered via scan } } } /** * 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 { $hash = substr(md5($content), 0, 8); $path = storage_path('rsx-tmp/bundle_' . $this->bundle_name . '_' . $hash . '.' . $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); } /** * Generate concrete model classes for PHP models in the bundle * * For each PHP model (subclass of Rsx_Model_Abstract) in the bundle: * 1. Check if a user-defined JS class with the same name exists * 2. If user-defined class exists: * - Validate it extends Base_{ModelName} directly * - If it exists in manifest but not in bundle, throw error * 3. If no user-defined class exists: * - Auto-generate: class ModelName extends Base_ModelName {} * * @param array $current_js_files JS files already in the bundle (to check for user classes) * @return string|null Path to temp file containing generated classes, or null if none needed */ protected function _generate_concrete_model_classes(array $current_js_files): ?string { $manifest = Manifest::get_full_manifest(); $manifest_files = $manifest['data']['files'] ?? []; // Get all files from all bundles to find PHP models $all_bundle_files = []; foreach ($this->bundle_files as $type => $files) { if (is_array($files)) { $all_bundle_files = array_merge($all_bundle_files, $files); } } // Build a set of JS class names currently in the bundle for quick lookup $js_classes_in_bundle = []; foreach ($current_js_files as $js_file) { $relative = str_replace(base_path() . '/', '', $js_file); if (isset($manifest_files[$relative]['class'])) { $js_classes_in_bundle[$manifest_files[$relative]['class']] = $relative; } } // Find all PHP models in the bundle $models_in_bundle = []; foreach ($all_bundle_files as $file) { // Only consider .php files for PHP models if (!str_ends_with($file, '.php')) { continue; } $relative = str_replace(base_path() . '/', '', $file); // Check if this is a PHP file with a class if (!isset($manifest_files[$relative]['class'])) { continue; } $class_name = $manifest_files[$relative]['class']; // Check if this class is a subclass of Rsx_Model_Abstract if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Model_Abstract')) { continue; } // Skip system models (internal framework models not exposed to JavaScript) if (Manifest::php_is_subclass_of($class_name, 'Rsx_System_Model_Abstract')) { continue; } // Skip abstract model classes - only concrete models get JS stubs if (Manifest::php_is_abstract($class_name)) { continue; } $models_in_bundle[$class_name] = $relative; } if (empty($models_in_bundle)) { return null; } console_debug('BUNDLE', 'Found ' . count($models_in_bundle) . ' PHP models in bundle: ' . implode(', ', array_keys($models_in_bundle))); // Process each model $generated_classes = []; $base_class_name = config('rsx.js_model_base_class'); foreach ($models_in_bundle as $model_name => $model_path) { $expected_base_class = 'Base_' . $model_name; // Check if user has defined a JS class with this model name $user_js_class_path = null; foreach ($manifest_files as $file_path => $meta) { if (isset($meta['class']) && $meta['class'] === $model_name && isset($meta['extension']) && $meta['extension'] === 'js') { // Make sure it's not a generated stub if (!isset($meta['is_model_stub']) && !isset($meta['is_stub'])) { $user_js_class_path = $file_path; break; } } } if ($user_js_class_path) { // User has defined a JS class for this model - validate it console_debug('BUNDLE', "Found user-defined JS class for {$model_name} at {$user_js_class_path}"); // Check if it's in the bundle if (!isset($js_classes_in_bundle[$model_name])) { throw new RuntimeException( "PHP model '{$model_name}' is included in bundle (at {$model_path}) " . "but its custom JavaScript implementation exists at '{$user_js_class_path}' " . "and is NOT included in the bundle.\n\n" . "Either:\n" . "1. Add the JS file's directory to the bundle's include paths, or\n" . "2. Remove the custom JS implementation to use auto-generated class" ); } // Validate it extends the Base_ class directly $user_meta = $manifest_files[$user_js_class_path] ?? []; $user_extends = $user_meta['extends'] ?? null; if ($user_extends !== $expected_base_class) { throw new RuntimeException( "JavaScript model class '{$model_name}' at '{$user_js_class_path}' " . "must extend '{$expected_base_class}' directly.\n" . "Found: extends " . ($user_extends ?: '(nothing)') . "\n\n" . "Correct usage:\n" . "class {$model_name} extends {$expected_base_class} {\n" . " // Your custom model methods\n" . "}" ); } console_debug('BUNDLE', "Validated {$model_name} extends {$expected_base_class}"); } else { // No user-defined class - auto-generate one console_debug('BUNDLE', "Auto-generating concrete class for {$model_name}"); $generated_classes[] = "class {$model_name} extends {$expected_base_class} {}"; } } if (empty($generated_classes)) { return null; } // Write all generated classes to a single temp file using standard temp file pattern $content = "/**\n"; $content .= " * Auto-generated concrete model classes\n"; $content .= " * These classes extend the Base_* stubs to provide usable model classes\n"; $content .= " * when no custom implementation is defined by the developer.\n"; $content .= " */\n\n"; $content .= implode("\n\n", $generated_classes) . "\n"; // Use content hash for idempotent file naming, with recognizable prefix for detection $hash = substr(md5($content), 0, 8); $temp_file = storage_path('rsx-tmp/bundle_generated_models_' . $this->bundle_name . '_' . $hash . '.js'); file_put_contents($temp_file, $content); console_debug('BUNDLE', 'Generated ' . count($generated_classes) . ' concrete model classes'); return $temp_file; } /** * 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; } // Check if this is a JS stub file (not in manifest, needs parsing) // Stub files are in storage/rsx-build/js-stubs/ or storage/rsx-build/js-model-stubs/ if (str_contains($file, 'storage/rsx-build/js-stubs/') || str_contains($file, 'storage/rsx-build/js-model-stubs/')) { // Use simple regex extraction - stub files have known format and can't use // the strict JS parser (stubs may have code after class declaration) $stub_content = file_get_contents($file); $stub_metadata = $this->_extract_stub_class_info($stub_content); if (!empty($stub_metadata['class'])) { $class_files[] = $file; $class_info[$file] = [ 'class' => $stub_metadata['class'], 'extends' => $stub_metadata['extends'], 'decorators' => [], 'method_decorators' => [], ]; console_debug('BUNDLE_SORT', "Parsed stub file: {$stub_metadata['class']} extends " . ($stub_metadata['extends'] ?? 'nothing')); } else { $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; } /** * Extract class name and extends from JS stub file content * * Uses simple regex extraction since stub files have a known format and may * have code after the class declaration that the strict JS parser rejects. * * @param string $content The stub file content * @return array ['class' => string|null, 'extends' => string|null] */ protected function _extract_stub_class_info(string $content): array { // Remove single-line comments $content = preg_replace('#//.*$#m', '', $content); // Remove multi-line comments (including JSDoc) $content = preg_replace('#/\*.*?\*/#s', '', $content); // Match: class ClassName or class ClassName extends ParentClass // The first match wins - we only care about the class declaration if (preg_match('/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+extends\s+([A-Za-z_][A-Za-z0-9_]*))?/', $content, $matches)) { return [ 'class' => $matches[1], 'extends' => $matches[2] ?? null, ]; } return ['class' => null, 'extends' => null]; } /** * 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: * a. JS stubs (Base_* model classes, controller stubs, etc.) * b. Compiled jqhtml templates * c. Concrete model classes (auto-generated or validated user-defined) * d. Manifest definitions (registers all JS classes) * e. Route definitions * f. Initialization runner (LAST - starts the application) * 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 } // Add JS stubs to app bundle only (they depend on Rsx_Js_Model which is in app) // Add them BEFORE dependency ordering so they're properly sorted if ($type === 'app') { $stub_files = $this->_get_js_stubs(); foreach ($stub_files as $stub) { $files['js'][] = $stub; } } // Order JavaScript files by class dependency BEFORE adding other 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($this->bundle_name . '_' . $type), 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 framework code to app JS // Note: JS stubs are already added before dependency ordering above 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); } } // Add compiled jqhtml files // These are JavaScript files generated from .jqhtml templates foreach ($this->jqhtml_compiled_files as $jqhtml_file) { $files['js'][] = $jqhtml_file; } // Generate concrete model classes for PHP models in the bundle // This validates user-defined JS model classes and auto-generates missing ones $concrete_models_file = $this->_generate_concrete_model_classes($files['js']); if ($concrete_models_file) { $files['js'][] = $concrete_models_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_files = $files['js']; // CDN assets are served separately via /_vendor/ URLs, not merged into bundle // This avoids complex concatenation issues with third-party code $js_content = $this->_compile_js_files($js_files); // Minify JS in production mode only (strips sourcemaps) if (Rsx_Mode::is_production()) { $js_content = Minifier::minify_js($js_content, "{$this->bundle_name}__{$type}.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_files = $files['css']; // CDN assets are served separately via /_vendor/ URLs, not merged into bundle // This avoids complex concatenation issues with third-party code $css_content = $this->_compile_css_files($css_files); // Minify CSS in production mode only (strips sourcemaps) if (Rsx_Mode::is_production()) { $css_content = Minifier::minify_css($css_content, "{$this->bundle_name}__{$type}.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_' . $this->bundle_name . '.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, already processed files, and CDN cache files // CDN files are third-party production code - don't transform them if (str_contains($file, 'storage/rsx-tmp/') || str_contains($file, 'storage/rsx-build/') || str_contains($file, '.cdn-cache/')) { continue; } // Transform the file (will use cache if available) try { $transformed_code = \App\RSpade\Core\JsParsers\Js_Transformer::transform($file); // Write transformed code to a temp file $temp_file = storage_path('rsx-tmp/babel_' . md5($file) . '.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_' . $this->bundle_name . '.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; // If this is a babel-transformed file, pass metadata to concat-js // Format: babel_file_path::original_file_path if (isset($this->babel_file_mapping[$file])) { $cmd_parts[] = escapeshellarg($file_to_use . '::' . $file); } else { $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); 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 $output_file = storage_path('rsx-tmp/css_bundle_' . $this->bundle_name . '.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); return $css; } /** * Prepare CDN assets for rendering * * In development mode: returns assets as-is (loaded from CDN URLs) * In production-like modes: ensures assets are cached and adds cached_filename * so rendering can use /_vendor/{filename} URLs * * @param array $assets CDN assets array * @param string $type 'js' or 'css' * @return array Prepared assets with cached_filename in production modes */ protected function _prepare_cdn_assets(array $assets, string $type): array { // In development mode, return as-is (use CDN URLs directly) if (!$this->is_production) { return $assets; } // In production-like modes, ensure cached and add filename $prepared = []; foreach ($assets as $asset) { $url = $asset['url'] ?? ''; if (empty($url)) { continue; } // Ensure the asset is cached (downloads if not already) Cdn_Cache::get($url, $type); // Add cached filename for /_vendor/ URL generation $asset['cached_filename'] = Cdn_Cache::get_cache_filename($url, $type); $prepared[] = $asset; } return $prepared; } /** * Get local file paths for cached CDN assets (DEPRECATED) * * Used in production-like modes to include CDN assets in concat scripts * for proper sourcemap handling. * * @param string $type 'js' or 'css' * @return array Array of local file paths to cached CDN files * @deprecated CDN assets are now served via /_vendor/ URLs, not merged into bundles */ protected function _get_cdn_cache_file_paths(string $type): array { $file_paths = []; $assets = $this->cdn_assets[$type] ?? []; if (empty($assets)) { return $file_paths; } // Sort assets: jQuery first, then others alphabetically $jquery_assets = []; $other_assets = []; foreach ($assets as $asset) { $url = $asset['url'] ?? ''; if (stripos($url, 'jquery') !== false) { $jquery_assets[] = $asset; } else { $other_assets[] = $asset; } } usort($other_assets, function ($a, $b) { return strcmp($a['url'] ?? '', $b['url'] ?? ''); }); $sorted_assets = array_merge($jquery_assets, $other_assets); // Get cache file path for each asset (downloads if not cached) // If download fails, let it throw - CDN assets are required foreach ($sorted_assets as $asset) { $url = $asset['url'] ?? ''; if (empty($url)) { continue; } // This will download and cache if not already cached Cdn_Cache::get($url, $type); // Get the cache file path $cache_path = Cdn_Cache::get_cache_path($url, $type); if (file_exists($cache_path)) { $file_paths[] = $cache_path; } } return $file_paths; } /** * 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 most temp files, but handle auto-generated model classes if (str_contains($file, 'storage/rsx-tmp/')) { // Check if this is the auto-generated model classes file if (str_contains($file, 'bundle_generated_models_')) { // Parse simple class declarations: class Foo extends Bar {} $content = file_get_contents($file); if (preg_match_all('/class\s+([A-Za-z_][A-Za-z0-9_]*)\s+extends\s+([A-Za-z_][A-Za-z0-9_]*)/', $content, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $class_definitions[$match[1]] = [ 'name' => $match[1], 'extends' => $match[2], 'decorators' => null, ]; } } } continue; } // Check if this is a JS stub file (not in PHP manifest, needs direct parsing) // Stub files are in storage/rsx-build/js-stubs/ or storage/rsx-build/js-model-stubs/ if (str_contains($file, 'storage/rsx-build/js-stubs/') || str_contains($file, 'storage/rsx-build/js-model-stubs/')) { $stub_content = file_get_contents($file); $stub_metadata = $this->_extract_stub_class_info($stub_content); if (!empty($stub_metadata['class'])) { $class_definitions[$stub_metadata['class']] = [ 'name' => $stub_metadata['class'], 'extends' => $stub_metadata['extends'], 'decorators' => null, // Stubs don't have method decorators ]; } continue; } // Get relative path for manifest lookup $relative = str_replace(base_path() . '/', '', $file); // Get file data from manifest $file_data = $manifest_files[$relative] ?? null; // All JavaScript files MUST be in manifest if ($file_data === null) { throw new \RuntimeException( "JavaScript file in bundle but not in manifest (this should never happen):\n" . "File: {$file}\n" . "Relative: {$relative}\n" . "Bundle: {$this->bundle_name}\n" . "Manifest has " . count($manifest_files) . " files total" ); } // If file has a class, add to class definitions 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, ]; } // Otherwise, it's a standalone function file - no manifest registration needed } // 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'); } /** * 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['public_static_methods'] ?? [] as $method_name => $method_data) { foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) { if ($attr_name === 'Route') { // Collect all route patterns for this method (supports multiple #[Route] attributes) foreach ($attr_instances as $instance) { $route_pattern = $instance[0] ?? null; if ($route_pattern) { console_debug('BUNDLE', " Found route: {$class_name}::{$method_name} => {$route_pattern}"); // Initialize arrays if needed if (!isset($routes[$class_name])) { $routes[$class_name] = []; } if (!isset($routes[$class_name][$method_name])) { $routes[$class_name][$method_name] = []; } // Append route pattern to array $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['public_static_methods'] ?? [] as $method_name => $method_data) { foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) { if ($attr_name === 'Route') { // Collect all route patterns for this method (supports multiple #[Route] attributes) foreach ($attr_instances as $instance) { $route_pattern = $instance[0] ?? null; if ($route_pattern) { // Initialize arrays if needed if (!isset($routes[$class_name])) { $routes[$class_name] = []; } if (!isset($routes[$class_name][$method_name])) { $routes[$class_name][$method_name] = []; } // Append route pattern to array $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; } }