Files
rspade_system/app/RSpade/Core/Bundle/BundleCompiler.php
root d523f0f600 Fix code quality violations and exclude Manifest from checks
Document application modes (development/debug/production)
Add global file drop handler, order column normalization, SPA hash fix
Serve CDN assets via /_vendor/ URLs instead of merging into bundles
Add production minification with license preservation
Improve JSON formatting for debugging and production optimization
Add CDN asset caching with CSS URL inlining for production builds
Add three-mode system (development, debug, production)
Update Manifest CLAUDE.md to reflect helper class architecture
Refactor Manifest.php into helper classes for better organization
Pre-manifest-refactor checkpoint: Add app_mode documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-14 10:38:22 +00:00

2853 lines
108 KiB
PHP

<?php
namespace App\RSpade\Core\Bundle;
use Exception;
use RecursiveCallbackFilterIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use App\RSpade\Core\Bundle\Cdn_Cache;
use App\RSpade\Core\Bundle\Minifier;
use App\RSpade\Core\Bundle\Rsx_Asset_Bundle_Abstract;
use App\RSpade\Core\Bundle\Rsx_Module_Bundle_Abstract;
use App\RSpade\Core\Locks\RsxLocks;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Mode\Rsx_Mode;
/**
* BundleCompiler - Compiles RSX bundles into JS and CSS files
*
* Process flow:
* 1. Resolve all bundle includes to get flat file list
* 2. In production: check if output files exist, return immediately if yes
* 3. Split all files into vendor/app based on path containing "vendor/"
* 4. Check individual vendor/app bundle caches
* 5. Process files through bundle processors
* 6. Add JS stubs from manifest
* 7. Filter to JS/CSS only
* 8. Add NPM includes to vendor bucket
* 9. Compile vendor JS/CSS, then app JS/CSS
* 10. In production only: concatenate vendor+app into single files
*/
#[Instantiatable]
class BundleCompiler
{
/**
* Files organized by vendor/app
* ['vendor' => [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)
if (is_string($include) && Manifest::php_find_class($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;
}
}