Remove unused blade settings pages not linked from UI Convert remaining frontend pages to SPA actions Convert settings user_settings and general to SPA actions Convert settings profile pages to SPA actions Convert contacts and projects add/edit pages to SPA actions Convert clients add/edit page to SPA action with loading pattern Refactor component scoped IDs from $id to $sid Fix jqhtml comment syntax and implement universal error component system Update all application code to use new unified error system Remove all backwards compatibility - unified error system complete Phase 5: Remove old response classes Phase 3-4: Ajax response handler sends new format, old helpers deprecated Phase 2: Add client-side unified error foundation Phase 1: Add server-side unified error foundation Add unified Ajax error response system with constants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2296 lines
84 KiB
PHP
Executable File
2296 lines
84 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\RSpade\Core\Bundle;
|
|
|
|
use Exception;
|
|
use RecursiveCallbackFilterIterator;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use RuntimeException;
|
|
use App\RSpade\Core\Locks\RsxLocks;
|
|
use App\RSpade\Core\Manifest\Manifest;
|
|
|
|
/**
|
|
* 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 = [];
|
|
|
|
/**
|
|
* 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 = app()->environment('production');
|
|
|
|
console_debug('BUNDLE', "Compiling {$this->bundle_name} (production: " . ($this->is_production ? 'yes' : 'no') . ')');
|
|
|
|
// Step 1: Check production cache
|
|
if ($this->is_production) {
|
|
$existing = $this->_check_production_cache();
|
|
if ($existing) {
|
|
console_debug('BUNDLE', 'Using existing production bundle');
|
|
|
|
return $existing;
|
|
}
|
|
}
|
|
|
|
// Step 2: Mark the bundle we're compiling as already resolved
|
|
$this->resolved_includes[$bundle_class] = true;
|
|
|
|
// Step 3: Process required bundles first
|
|
$this->_process_required_bundles();
|
|
|
|
// Step 4: Resolve all bundle includes to get flat file list
|
|
$this->_resolve_bundle($bundle_class);
|
|
|
|
// Step 5: Always split into vendor/app
|
|
$this->_split_vendor_app();
|
|
|
|
// Step 6: Check individual bundle caches
|
|
$need_compile = $this->_check_bundle_caches();
|
|
console_debug('BUNDLE', 'Need compile: ' . json_encode($need_compile));
|
|
|
|
// Step 7-10: Process bundles that need compilation
|
|
if (!empty($need_compile)) {
|
|
console_debug('BUNDLE', '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
|
|
$bundle_dir = storage_path('rsx-build/bundles');
|
|
$result = [];
|
|
|
|
// Add CDN assets
|
|
if (!empty($this->cdn_assets['js'])) {
|
|
$result['cdn_js'] = $this->cdn_assets['js'];
|
|
}
|
|
if (!empty($this->cdn_assets['css'])) {
|
|
$result['cdn_css'] = $this->cdn_assets['css'];
|
|
}
|
|
|
|
// Add 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'];
|
|
}
|
|
|
|
// Add bundle file paths for development
|
|
if (!$this->is_production) {
|
|
if (isset($outputs['vendor_js'])) {
|
|
$result['vendor_js_bundle_path'] = $outputs['vendor_js'];
|
|
}
|
|
if (isset($outputs['app_js'])) {
|
|
$result['app_js_bundle_path'] = $outputs['app_js'];
|
|
}
|
|
if (isset($outputs['vendor_css'])) {
|
|
$result['vendor_css_bundle_path'] = $outputs['vendor_css'];
|
|
}
|
|
if (isset($outputs['app_css'])) {
|
|
$result['app_css_bundle_path'] = $outputs['app_css'];
|
|
}
|
|
} else {
|
|
// Production mode - simple concatenation of vendor + app
|
|
$js_content = '';
|
|
if (isset($outputs['vendor_js'])) {
|
|
$js_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_js']}");
|
|
}
|
|
if (isset($outputs['app_js'])) {
|
|
// Simple concatenation with newline separator
|
|
if ($js_content) {
|
|
$js_content .= "\n";
|
|
}
|
|
$js_content .= file_get_contents("{$bundle_dir}/{$outputs['app_js']}");
|
|
}
|
|
|
|
$css_content = '';
|
|
if (isset($outputs['vendor_css'])) {
|
|
$css_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_css']}");
|
|
}
|
|
if (isset($outputs['app_css'])) {
|
|
// Simple concatenation with newline separator
|
|
if ($css_content) {
|
|
$css_content .= "\n";
|
|
}
|
|
$css_content .= file_get_contents("{$bundle_dir}/{$outputs['app_css']}");
|
|
}
|
|
|
|
// Write combined files with content hash
|
|
if ($js_content) {
|
|
$js_hash = substr(md5($js_content), 0, 16);
|
|
$js_file = "app.{$js_hash}.js";
|
|
file_put_contents("{$bundle_dir}/{$js_file}", $js_content);
|
|
$result['js_bundle_path'] = $js_file;
|
|
}
|
|
|
|
if ($css_content) {
|
|
$css_hash = substr(md5($css_content), 0, 16);
|
|
$css_file = "app.{$css_hash}.css";
|
|
file_put_contents("{$bundle_dir}/{$css_file}", $css_content);
|
|
$result['css_bundle_path'] = $css_file;
|
|
}
|
|
}
|
|
|
|
// Add config if present
|
|
if (!empty($this->config)) {
|
|
$result['bundle_data'] = $this->config;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
// ------------------------------------------------------------------------
|
|
// ---- Private / Protected Methods:
|
|
// ------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Check for JavaScript naming conflicts within files being bundled
|
|
*
|
|
* This checks for:
|
|
* - Duplicate function names
|
|
* - Duplicate const names
|
|
* - Duplicate class names
|
|
* - Conflicts between functions, consts, and classes
|
|
*
|
|
* Only checks the subset of files being bundled, not the entire manifest.
|
|
* Throws exception on first conflict found to prevent bundle compilation.
|
|
*
|
|
* @param array $types Types being compiled (vendor/app)
|
|
* @throws \App\RSpade\Core\Exceptions\YoureDoingItWrongException
|
|
*/
|
|
protected function _check_js_naming_conflicts(array $types): void
|
|
{
|
|
// Collect all JavaScript files from types being compiled
|
|
$js_files = [];
|
|
foreach ($types as $type) {
|
|
$files = $this->bundle_files[$type] ?? [];
|
|
foreach ($files as $file) {
|
|
// Only check JavaScript files
|
|
if (str_ends_with($file, '.js')) {
|
|
$js_files[] = $file;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($js_files)) {
|
|
return; // No JS files to check
|
|
}
|
|
|
|
console_debug('BUNDLE', 'Checking JS naming conflicts for ' . count($js_files) . ' files');
|
|
|
|
// Get manifest data
|
|
$manifest_files = Manifest::get_all();
|
|
|
|
// Track all names across the bundle
|
|
$all_names = []; // name => ['type' => 'function'|'const'|'class', 'file' => path]
|
|
|
|
// Process each JS file in the bundle
|
|
foreach ($js_files as $js_file) {
|
|
// Convert absolute path to relative for manifest lookup
|
|
$relative_path = str_replace(base_path() . '/', '', $js_file);
|
|
|
|
if (!isset($manifest_files[$relative_path])) {
|
|
continue; // File not in manifest, skip
|
|
}
|
|
|
|
$metadata = $manifest_files[$relative_path];
|
|
|
|
// Check global functions
|
|
if (!empty($metadata['global_function_names'])) {
|
|
foreach ($metadata['global_function_names'] as $func_name) {
|
|
if (isset($all_names[$func_name])) {
|
|
$this->_throw_js_naming_conflict(
|
|
$func_name,
|
|
'function',
|
|
$relative_path,
|
|
$all_names[$func_name]['type'],
|
|
$all_names[$func_name]['file']
|
|
);
|
|
}
|
|
$all_names[$func_name] = ['type' => 'function', 'file' => $relative_path];
|
|
}
|
|
}
|
|
|
|
// Check global consts
|
|
if (!empty($metadata['global_const_names'])) {
|
|
foreach ($metadata['global_const_names'] as $const_name) {
|
|
if (isset($all_names[$const_name])) {
|
|
$this->_throw_js_naming_conflict(
|
|
$const_name,
|
|
'const',
|
|
$relative_path,
|
|
$all_names[$const_name]['type'],
|
|
$all_names[$const_name]['file']
|
|
);
|
|
}
|
|
$all_names[$const_name] = ['type' => 'const', 'file' => $relative_path];
|
|
}
|
|
}
|
|
|
|
// Check class names (they share namespace with functions and consts)
|
|
if (!empty($metadata['class'])) {
|
|
$class_name = $metadata['class'];
|
|
if (isset($all_names[$class_name])) {
|
|
$this->_throw_js_naming_conflict(
|
|
$class_name,
|
|
'class',
|
|
$relative_path,
|
|
$all_names[$class_name]['type'],
|
|
$all_names[$class_name]['file']
|
|
);
|
|
}
|
|
$all_names[$class_name] = ['type' => 'class', 'file' => $relative_path];
|
|
}
|
|
}
|
|
|
|
console_debug('BUNDLE', 'JS naming check passed - no conflicts found');
|
|
}
|
|
|
|
/**
|
|
* Throw exception for JavaScript naming conflict
|
|
*/
|
|
protected function _throw_js_naming_conflict(string $name, string $type1, string $file1, string $type2, string $file2): void
|
|
{
|
|
$error_message = '==========================================';
|
|
$error_message .= "\nFATAL: JavaScript naming conflict in bundle";
|
|
$error_message .= "\n==========================================";
|
|
$error_message .= "\n\nBundle: {$this->bundle_name}";
|
|
$error_message .= "\nName: {$name}";
|
|
$error_message .= "\n\nDefined as {$type1} in: {$file1}";
|
|
$error_message .= "\nAlso defined as {$type2} in: {$file2}";
|
|
$error_message .= "\n\nPROBLEM:";
|
|
$error_message .= "\nThe name '{$name}' is used for both a {$type1} and a {$type2}.";
|
|
$error_message .= "\nJavaScript files in the same bundle share the same scope.";
|
|
|
|
if ($type1 === $type2) {
|
|
$error_message .= "\nDuplicate {$type1} definitions will conflict.";
|
|
} else {
|
|
$error_message .= "\nClasses, functions and const variables share the same namespace.";
|
|
}
|
|
|
|
$error_message .= "\n\nWHY THIS MATTERS:";
|
|
$error_message .= "\n- All files in a bundle are concatenated together";
|
|
$error_message .= "\n- Duplicate names overwrite each other or cause errors";
|
|
$error_message .= "\n- This leads to unpredictable runtime behavior";
|
|
$error_message .= "\n\nFIX OPTIONS:";
|
|
$error_message .= "\n1. Rename one of them to be unique";
|
|
$error_message .= "\n2. Move into a class as a static member";
|
|
$error_message .= "\n3. Remove from bundle if not needed";
|
|
$error_message .= "\n4. Use different bundles for conflicting files";
|
|
$error_message .= "\n==========================================";
|
|
|
|
throw new \App\RSpade\Core\Exceptions\YoureDoingItWrongException($error_message);
|
|
}
|
|
|
|
/**
|
|
* Get bundle name from class
|
|
*/
|
|
protected function _get_bundle_name(string $bundle_class): string
|
|
{
|
|
$parts = explode('\\', $bundle_class);
|
|
|
|
return end($parts);
|
|
}
|
|
|
|
/**
|
|
* Check if production bundle already exists
|
|
*/
|
|
protected function _check_production_cache(): ?array
|
|
{
|
|
$bundle_dir = storage_path('rsx-build/bundles');
|
|
$js_pattern = "{$bundle_dir}/app.*.js";
|
|
$css_pattern = "{$bundle_dir}/app.*.css";
|
|
|
|
$js_files = glob($js_pattern);
|
|
$css_files = glob($css_pattern);
|
|
|
|
if (!empty($js_files) || !empty($css_files)) {
|
|
return [
|
|
'js_bundle_path' => !empty($js_files) ? basename($js_files[0]) : null,
|
|
'css_bundle_path' => !empty($css_files) ? basename($css_files[0]) : null,
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Process required bundles (jquery, lodash, jqhtml)
|
|
*/
|
|
protected function _process_required_bundles(): void
|
|
{
|
|
$required_bundles = config('rsx.required_bundles', []);
|
|
$bundle_aliases = config('rsx.bundle_aliases', []);
|
|
|
|
foreach ($required_bundles as $alias) {
|
|
if (isset($bundle_aliases[$alias])) {
|
|
$this->_process_include_item($bundle_aliases[$alias]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve bundle and all its includes
|
|
*/
|
|
protected function _resolve_bundle(string $bundle_class): void
|
|
{
|
|
// Get bundle definition
|
|
if (!method_exists($bundle_class, 'define')) {
|
|
throw new Exception("Bundle {$bundle_class} missing define() method");
|
|
}
|
|
|
|
$definition = $bundle_class::define();
|
|
|
|
// Process bundle includes
|
|
if (!empty($definition['include'])) {
|
|
foreach ($definition['include'] as $item) {
|
|
$this->_process_include_item($item);
|
|
}
|
|
}
|
|
|
|
// Process watch directories
|
|
if (!empty($definition['watch'])) {
|
|
foreach ($definition['watch'] as $dir) {
|
|
$this->_add_watch_directory($dir);
|
|
}
|
|
}
|
|
|
|
// Store CDN assets
|
|
if (!empty($definition['cdn_assets'])) {
|
|
if (!empty($definition['cdn_assets']['js'])) {
|
|
$this->cdn_assets['js'] = array_merge($this->cdn_assets['js'], $definition['cdn_assets']['js']);
|
|
}
|
|
if (!empty($definition['cdn_assets']['css'])) {
|
|
$this->cdn_assets['css'] = array_merge($this->cdn_assets['css'], $definition['cdn_assets']['css']);
|
|
}
|
|
}
|
|
|
|
// Store NPM includes
|
|
if (!empty($definition['npm'])) {
|
|
$this->npm_includes = array_merge($this->npm_includes, $definition['npm']);
|
|
}
|
|
|
|
// Store config
|
|
if (!empty($definition['config'])) {
|
|
$this->config = array_merge($this->config, $definition['config']);
|
|
}
|
|
|
|
// Process include_routes directive
|
|
if (!empty($definition['include_routes'])) {
|
|
foreach ($definition['include_routes'] as $route_path) {
|
|
// Normalize the path
|
|
$normalized_path = trim($route_path, '/');
|
|
if (!in_array($normalized_path, $this->include_routes)) {
|
|
$this->include_routes[] = $normalized_path;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a single include item
|
|
*/
|
|
protected function _process_include_item($item): void
|
|
{
|
|
// Create a unique key for this include item
|
|
$include_key = is_string($item) ? $item : serialize($item);
|
|
|
|
// Skip if already processed
|
|
if (isset($this->resolved_includes[$include_key])) {
|
|
return;
|
|
}
|
|
$this->resolved_includes[$include_key] = true;
|
|
|
|
// Check 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
|
|
*/
|
|
protected function _add_directory(string $path): void
|
|
{
|
|
if (!is_dir($path)) {
|
|
return;
|
|
}
|
|
|
|
// Get excluded directories from config
|
|
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
|
|
|
|
// Create a recursive directory iterator with filtering
|
|
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
|
|
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
|
|
// Check if current directory name is in excluded list
|
|
if ($current->isDir()) {
|
|
$dirname = $current->getBasename();
|
|
|
|
return !in_array($dirname, $excluded_dirs);
|
|
}
|
|
|
|
// Include all files
|
|
return true;
|
|
});
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
$filter,
|
|
RecursiveIteratorIterator::SELF_FIRST
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile()) {
|
|
$this->_add_file($file->getPathname());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a watch directory
|
|
*/
|
|
protected function _add_watch_directory(string $dir): void
|
|
{
|
|
$path = base_path($dir);
|
|
if (!is_dir($path)) {
|
|
return;
|
|
}
|
|
|
|
// Get excluded directories from config
|
|
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
|
|
|
|
// Create a custom filter to exclude configured directories
|
|
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
|
|
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
|
|
// Skip excluded directories
|
|
if ($current->isDir() && in_array($current->getBasename(), $excluded_dirs)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
$filter,
|
|
RecursiveIteratorIterator::SELF_FIRST
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile()) {
|
|
$normalized = rsxrealpath($file->getPathname());
|
|
if (!isset($this->included_files[$normalized])) {
|
|
if (!isset($this->watch_files['all'])) {
|
|
$this->watch_files['all'] = [];
|
|
}
|
|
$this->watch_files['all'][] = $normalized;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Split files into vendor and app buckets
|
|
*
|
|
* IMPORTANT: Files remain as FLAT ARRAYS at this stage.
|
|
* We are extension-agnostic throughout processing.
|
|
* Files could be .php, .blade, .scss, .jqhtml, .xml, .coffee, or ANY future type.
|
|
* Only at final compilation do we filter by .js and .css extensions.
|
|
*/
|
|
protected function _split_vendor_app(): void
|
|
{
|
|
$vendor_files = [];
|
|
$app_files = [];
|
|
|
|
// Split bundle files - flat array, ALL file types mixed together
|
|
foreach ($this->bundle_files['all'] ?? [] as $file) {
|
|
if (strpos($file, '/vendor/') !== false) {
|
|
$vendor_files[] = $file;
|
|
} else {
|
|
$app_files[] = $file;
|
|
}
|
|
}
|
|
|
|
// Store as flat arrays - NOT organized by extension yet
|
|
$this->bundle_files = [
|
|
'vendor' => $vendor_files, // Flat array of ALL vendor files
|
|
'app' => $app_files, // Flat array of ALL app files
|
|
];
|
|
|
|
// Split watch files - also flat arrays
|
|
$vendor_watch = [];
|
|
$app_watch = [];
|
|
|
|
foreach ($this->watch_files['all'] ?? [] as $file) {
|
|
if (strpos($file, '/vendor/') !== false) {
|
|
$vendor_watch[] = $file;
|
|
} else {
|
|
$app_watch[] = $file;
|
|
}
|
|
}
|
|
|
|
$this->watch_files = [
|
|
'vendor' => $vendor_watch, // Flat array of ALL vendor watch files
|
|
'app' => $app_watch, // Flat array of ALL app watch files
|
|
];
|
|
|
|
console_debug('BUNDLE', 'Split files - vendor: ' . count($vendor_files) . ', app: ' . count($app_files));
|
|
}
|
|
|
|
/**
|
|
* Check bundle caches and return which need compilation
|
|
*/
|
|
protected function _check_bundle_caches(): array
|
|
{
|
|
$need_compile = [];
|
|
$this->cache_keys = []; // Store for use in compile
|
|
|
|
foreach (['vendor', 'app'] as $type) {
|
|
$cache_key = $this->_get_cache_key($type);
|
|
$this->cache_keys[$type] = $cache_key;
|
|
$cached = $this->_get_cached_bundle($cache_key, $type);
|
|
|
|
if (!$cached || !$this->_is_cache_valid($cached, $type)) {
|
|
$need_compile[] = $type;
|
|
}
|
|
}
|
|
|
|
console_debug('BUNDLE', 'Need to compile: ' . implode(', ', $need_compile));
|
|
|
|
return $need_compile;
|
|
}
|
|
|
|
/**
|
|
* Get cache key for bundle type
|
|
*/
|
|
protected function _get_cache_key(string $type): string
|
|
{
|
|
$files_for_hash = array_merge(
|
|
$this->bundle_files[$type] ?? [],
|
|
$this->watch_files[$type] ?? []
|
|
);
|
|
|
|
$hashes = [];
|
|
foreach ($files_for_hash as $file) {
|
|
if (!file_exists($file)) {
|
|
shouldnt_happen("Expected file {$file} does not exist in bundle builder");
|
|
}
|
|
$hash = _rsx_file_hash_for_build($file);
|
|
$hashes[] = $hash;
|
|
}
|
|
|
|
$final_key = md5(serialize($hashes));
|
|
|
|
return $final_key;
|
|
}
|
|
|
|
/**
|
|
* Get cached bundle if exists
|
|
*/
|
|
protected function _get_cached_bundle(string $cache_key, string $type): ?array
|
|
{
|
|
$bundle_dir = storage_path('rsx-build/bundles');
|
|
// The cache key is used as part of the filename hash
|
|
// Look for files with the specific type and hash
|
|
$short_key = substr($cache_key, 0, 8);
|
|
$js_file = "{$bundle_dir}/{$this->bundle_name}__{$type}.{$short_key}.js";
|
|
$css_file = "{$bundle_dir}/{$this->bundle_name}__{$type}.{$short_key}.css";
|
|
|
|
$files = [];
|
|
if (file_exists($js_file)) {
|
|
$files[] = $js_file;
|
|
}
|
|
if (file_exists($css_file)) {
|
|
$files[] = $css_file;
|
|
}
|
|
|
|
if (empty($files)) {
|
|
console_debug('BUNDLE', "No cached files found for {$type} with hash {$short_key}");
|
|
|
|
return null;
|
|
}
|
|
|
|
console_debug('BUNDLE', "Found cached files for {$type} with hash {$short_key}");
|
|
|
|
return ['files' => $files, 'key' => $cache_key];
|
|
}
|
|
|
|
/**
|
|
* Check if cache is still valid
|
|
*/
|
|
protected function _is_cache_valid(array $cached, string $type): bool
|
|
{
|
|
// In production, if files exist, they're valid
|
|
if ($this->is_production) {
|
|
return true;
|
|
}
|
|
|
|
// Check if all cached files still exist
|
|
foreach ($cached['files'] as $file) {
|
|
if (!file_exists($file)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Process bundles through processors
|
|
*
|
|
* IMPORTANT: Files remain as FLAT ARRAYS throughout processing.
|
|
* Processors can handle ANY file type: .php, .jqhtml, .scss, .xml, .coffee, etc.
|
|
* We do NOT organize by extension here - only in _compile_outputs.
|
|
*/
|
|
protected function _process_bundles(array $types): void
|
|
{
|
|
// Check for JavaScript naming conflicts before processing
|
|
// This only runs when bundles actually need rebuilding
|
|
$this->_check_js_naming_conflicts($types);
|
|
|
|
$processor_classes = config('rsx.bundle_processors', []);
|
|
|
|
// Sort processors by priority (lower number = higher priority)
|
|
usort($processor_classes, function ($a, $b) {
|
|
$priority_a = method_exists($a, 'get_priority') ? $a::get_priority() : 1000;
|
|
$priority_b = method_exists($b, 'get_priority') ? $b::get_priority() : 1000;
|
|
|
|
return $priority_a - $priority_b;
|
|
});
|
|
|
|
foreach ($types as $type) {
|
|
// Get flat array of ALL files
|
|
$files = $this->bundle_files[$type] ?? [];
|
|
|
|
// Run each processor with ALL files (flat array, passed by reference)
|
|
foreach ($processor_classes as $processor_class) {
|
|
if (!class_exists($processor_class)) {
|
|
continue;
|
|
}
|
|
|
|
console_debug('BUNDLE', "Running {$processor_class} on {$type} files (" . count($files) . ' files)');
|
|
|
|
// Process files - processor modifies array directly by reference
|
|
if (method_exists($processor_class, 'process_batch')) {
|
|
$processor_class::process_batch($files);
|
|
}
|
|
}
|
|
|
|
// Update the bundle files - STILL AS FLAT ARRAY
|
|
// Do NOT organize by extension here!
|
|
$this->bundle_files[$type] = array_values($files); // Re-index array
|
|
|
|
console_debug('BUNDLE', "Processed {$type} - Total files: " . count($files));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write temporary file
|
|
*/
|
|
protected function _write_temp_file(string $content, string $extension): string
|
|
{
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* Order JavaScript files by class dependency
|
|
*
|
|
* JavaScript file ordering is critical for proper execution because:
|
|
* 1. Decorators are executed at class definition time (not invocation)
|
|
* 2. ES6 class extends clauses are evaluated at definition time
|
|
* 3. Both require their dependencies to be defined first
|
|
*
|
|
* Ordering rules:
|
|
* 1. Non-class files first (functions, constants, utilities)
|
|
* 2. Class files ordered by dependency (parent classes and decorator classes first)
|
|
* 3. Special metadata files (manifest, stubs) - added later by caller
|
|
* 4. Runner stub - added last by caller
|
|
*
|
|
* Dependency types:
|
|
* - Inheritance: class Foo extends Bar requires Bar to be defined first
|
|
* - Decorators: @Foo.bar() requires Foo class to be defined first
|
|
* - Simple decorators: @foobar() requires function foobar to be defined first
|
|
*
|
|
* We trust programmers to order non-class files correctly among themselves.
|
|
* We DO validate and enforce ordering for class dependencies.
|
|
*/
|
|
protected function _order_javascript_files_by_dependency(array $js_files): array
|
|
{
|
|
console_debug('BUNDLE_SORT', 'Starting dependency sort with ' . count($js_files) . ' files');
|
|
|
|
$manifest = Manifest::get_full_manifest();
|
|
$manifest_files = $manifest['data']['files'] ?? [];
|
|
|
|
// Arrays to hold categorized files
|
|
$class_files = [];
|
|
$jqhtml_compiled_files = [];
|
|
$non_class_files = [];
|
|
$class_info = [];
|
|
|
|
// Analyze each file for class information
|
|
foreach ($js_files as $file) {
|
|
// Check if this is a compiled jqhtml file
|
|
if (str_contains($file, 'storage/rsx-tmp/jqhtml_')) {
|
|
$jqhtml_compiled_files[] = $file;
|
|
continue;
|
|
}
|
|
|
|
// Skip ALL temp files - they won't be in manifest
|
|
// Babel and other transformations should have been applied to original files
|
|
if (str_contains($file, 'storage/rsx-tmp/')) {
|
|
$non_class_files[] = $file;
|
|
continue;
|
|
}
|
|
|
|
// Get file info from manifest
|
|
$relative = str_replace(base_path() . '/', '', $file);
|
|
$file_data = $manifest_files[$relative] ?? null;
|
|
|
|
// If file has a class defined, add to class_files
|
|
if (!empty($file_data['class'])) {
|
|
$class_files[] = $file;
|
|
|
|
// Build comprehensive class info including decorator dependencies
|
|
$class_info[$file] = [
|
|
'class' => $file_data['class'],
|
|
'extends' => $file_data['extends'] ?? null,
|
|
'decorators' => $file_data['decorators'] ?? [],
|
|
// Extract decorator dependencies from methods
|
|
'method_decorators' => $this->_extract_method_decorators($file_data),
|
|
];
|
|
} else {
|
|
// No class info - treat as non-class file
|
|
$non_class_files[] = $file;
|
|
}
|
|
}
|
|
|
|
// Order class files by dependency with circular dependency detection
|
|
$ordered_class_files = $this->_topological_sort_classes($class_files, $class_info);
|
|
|
|
// Find decorator.js and ensure it's the very first file
|
|
$decorator_file = null;
|
|
$other_non_class_files = [];
|
|
|
|
foreach ($non_class_files as $file) {
|
|
// Check if this is the decorator.js file
|
|
if (str_ends_with($file, '/app/RSpade/Core/Js/decorator.js')) {
|
|
$decorator_file = $file;
|
|
} else {
|
|
$other_non_class_files[] = $file;
|
|
}
|
|
}
|
|
|
|
// CRITICAL ORDERING:
|
|
// 1. decorator.js MUST be first (defines @decorator)
|
|
// 2. Other non-class files (utilities, decorator functions)
|
|
// 3. Class files (ordered by dependency)
|
|
// NOTE: Compiled jqhtml files are returned separately - will be inserted after JS stubs
|
|
$final_order = [];
|
|
if ($decorator_file) {
|
|
$final_order[] = $decorator_file;
|
|
}
|
|
$final_order = array_merge($final_order, $other_non_class_files, $ordered_class_files);
|
|
|
|
// Return both the ordered files and the jqhtml files separately
|
|
// Store jqhtml files in a property for use in _compile_outputs
|
|
$this->jqhtml_compiled_files = $jqhtml_compiled_files;
|
|
|
|
return $final_order;
|
|
}
|
|
|
|
/**
|
|
* Extract decorator dependencies from method metadata
|
|
*
|
|
* Scans all methods in the file metadata to find decorator usage.
|
|
* Decorators like @Foo.bar() create a dependency on class Foo.
|
|
*/
|
|
protected function _extract_method_decorators(array $file_data): array
|
|
{
|
|
$decorators = [];
|
|
|
|
// Check static methods for decorators
|
|
if (!empty($file_data['public_static_methods'])) {
|
|
foreach ($file_data['public_static_methods'] as $method) {
|
|
if (!empty($method['decorators'])) {
|
|
foreach ($method['decorators'] as $decorator) {
|
|
$decorators[] = $decorator;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check instance methods for decorators
|
|
if (!empty($file_data['methods'])) {
|
|
foreach ($file_data['methods'] as $method) {
|
|
if (!empty($method['decorators'])) {
|
|
foreach ($method['decorators'] as $decorator) {
|
|
$decorators[] = $decorator;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check properties for decorators
|
|
if (!empty($file_data['static_properties'])) {
|
|
foreach ($file_data['static_properties'] as $property) {
|
|
if (!empty($property['decorators'])) {
|
|
foreach ($property['decorators'] as $decorator) {
|
|
$decorators[] = $decorator;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $decorators;
|
|
}
|
|
|
|
/**
|
|
* Topological sort for class dependencies with decorator support
|
|
*
|
|
* This performs a depth-first topological sort to order JavaScript classes
|
|
* such that all dependencies are defined before the classes that use them.
|
|
*
|
|
* Dependency detection:
|
|
* 1. Inheritance: class Foo extends Bar creates dependency on Bar
|
|
* 2. Class decorators: @Baz.method() creates dependency on Baz class
|
|
* 3. Method decorators: Same as class decorators
|
|
*
|
|
* Circular dependency handling:
|
|
* - Detected via temporary visit marking during DFS traversal
|
|
* - Throws exception with clear error message showing the cycle
|
|
* - Prevents infinite loops in dependency resolution
|
|
*
|
|
* Note: We don't validate if decorator classes actually exist in the bundle.
|
|
* If @Foo.bar() is used and Foo doesn't exist, that's fine - it might be
|
|
* defined globally or in a different bundle. We only order known dependencies.
|
|
*/
|
|
protected function _topological_sort_classes(array $class_files, array $class_info): array
|
|
{
|
|
$sorted = [];
|
|
$visited = [];
|
|
$temp_visited = [];
|
|
$visit_path = []; // Track the current path for circular dependency reporting
|
|
|
|
// Build a map of class names to file paths for quick lookup
|
|
$class_to_file = [];
|
|
foreach ($class_info as $file => $info) {
|
|
if (isset($info['class'])) {
|
|
$class_to_file[$info['class']] = $file;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract class dependencies from a decorator name
|
|
*
|
|
* Examples:
|
|
* - @Foo.bar() returns 'Foo'
|
|
* - @Route() returns null (not a class dependency)
|
|
* - @Namespace.Class.method() returns 'Namespace' (first part only)
|
|
*/
|
|
$extract_decorator_class = function ($decorator_name) {
|
|
// Check if decorator name contains a dot (class.method pattern)
|
|
if (str_contains($decorator_name, '.')) {
|
|
// Extract the class name (part before first dot)
|
|
$parts = explode('.', $decorator_name);
|
|
// Only return if it starts with uppercase (likely a class)
|
|
if (!empty($parts[0]) && ctype_upper($parts[0][0])) {
|
|
return $parts[0];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Depth-first search for topological sorting
|
|
*
|
|
* This recursive function visits each node (file) and its dependencies,
|
|
* building the sorted list from the bottom up (dependencies first).
|
|
*/
|
|
$visit = function ($file) use (
|
|
&$visit,
|
|
&$sorted,
|
|
&$visited,
|
|
&$temp_visited,
|
|
&$visit_path,
|
|
$class_info,
|
|
$class_to_file,
|
|
$extract_decorator_class
|
|
) {
|
|
// Check for circular dependency
|
|
if (isset($temp_visited[$file])) {
|
|
// Build the cycle path for error reporting
|
|
$cycle_start = array_search($file, $visit_path);
|
|
$cycle = array_slice($visit_path, $cycle_start);
|
|
$cycle[] = $file; // Complete the circle
|
|
|
|
// Create detailed error message
|
|
$class_name = $class_info[$file]['class'] ?? 'unknown';
|
|
$cycle_description = array_map(function ($f) use ($class_info) {
|
|
return $class_info[$f]['class'] ?? basename($f);
|
|
}, $cycle);
|
|
|
|
throw new RuntimeException(
|
|
"Circular dependency detected in JavaScript class dependencies:\n" .
|
|
' ' . implode(' -> ', $cycle_description) . "\n" .
|
|
" Files involved:\n" .
|
|
implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', $f), $cycle)) . "\n" .
|
|
' Break the circular dependency by refactoring the class relationships.'
|
|
);
|
|
}
|
|
|
|
// Already processed this file
|
|
if (isset($visited[$file])) {
|
|
return;
|
|
}
|
|
|
|
// Mark as temporarily visited and add to path
|
|
$temp_visited[$file] = true;
|
|
$visit_path[] = $file;
|
|
|
|
// Collect all dependencies for this file
|
|
$dependencies = [];
|
|
|
|
// 1. Inheritance dependency: extends clause
|
|
if (!empty($class_info[$file]['extends'])) {
|
|
$parent_class = $class_info[$file]['extends'];
|
|
|
|
// Find the file containing the parent class
|
|
if (isset($class_to_file[$parent_class])) {
|
|
$parent_file = $class_to_file[$parent_class];
|
|
|
|
// Only add as dependency if it's a different file
|
|
if ($parent_file !== $file) {
|
|
$dependencies[] = $parent_file;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Class decorator dependencies: @Foo.bar() on the class itself
|
|
// Decorators are in compact format: [[name, [args]], ...]
|
|
if (!empty($class_info[$file]['decorators'])) {
|
|
foreach ($class_info[$file]['decorators'] as $decorator) {
|
|
// Compact format: [name, args]
|
|
$decorator_name = $decorator[0] ?? null;
|
|
if (!$decorator_name) {
|
|
continue;
|
|
}
|
|
|
|
$decorator_class = $extract_decorator_class($decorator_name);
|
|
|
|
if ($decorator_class && isset($class_to_file[$decorator_class])) {
|
|
$decorator_file = $class_to_file[$decorator_class];
|
|
|
|
// Only add as dependency if it's a different file
|
|
if ($decorator_file !== $file && !in_array($decorator_file, $dependencies)) {
|
|
$dependencies[] = $decorator_file;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Method decorator dependencies: decorators on methods/properties
|
|
if (!empty($class_info[$file]['method_decorators'])) {
|
|
foreach ($class_info[$file]['method_decorators'] as $decorator) {
|
|
$decorator_class = $extract_decorator_class($decorator['name']);
|
|
|
|
if ($decorator_class && isset($class_to_file[$decorator_class])) {
|
|
$decorator_file = $class_to_file[$decorator_class];
|
|
|
|
// Only add as dependency if it's a different file and not already added
|
|
if ($decorator_file !== $file && !in_array($decorator_file, $dependencies)) {
|
|
$dependencies[] = $decorator_file;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Visit all dependencies first (depth-first)
|
|
foreach ($dependencies as $dep_file) {
|
|
$visit($dep_file);
|
|
}
|
|
|
|
// Remove from temporary visited and path
|
|
unset($temp_visited[$file]);
|
|
array_pop($visit_path);
|
|
|
|
// Mark as permanently visited and add to sorted list
|
|
$visited[$file] = true;
|
|
$sorted[] = $file;
|
|
};
|
|
|
|
// Process all class files
|
|
foreach ($class_files as $file) {
|
|
if (!isset($visited[$file])) {
|
|
$visit($file); //??
|
|
}
|
|
}
|
|
|
|
// try {
|
|
// (code above was here)
|
|
// } catch (RuntimeException $e) {
|
|
// // Re-throw with bundle context if available
|
|
// if (!empty($this->bundle_class)) {
|
|
// throw new RuntimeException(
|
|
// "Bundle compilation failed for {$this->bundle_class}:\n" . $e->getMessage(),
|
|
// 0,
|
|
// $e
|
|
// );
|
|
// }
|
|
|
|
// throw $e;
|
|
// }
|
|
|
|
return $sorted;
|
|
}
|
|
|
|
/**
|
|
* Compile final output files
|
|
*
|
|
* THIS IS THE ONLY PLACE where we organize files by extension.
|
|
* Up until now, all files have been in flat arrays regardless of type.
|
|
* Here we:
|
|
* 1. Filter to only .js and .css files
|
|
* 2. Order JS files by class dependency
|
|
* 3. Add framework code (stubs, manifest, runner)
|
|
* 4. Generate final compiled output
|
|
*/
|
|
protected function _compile_outputs(array $types_to_compile = []): array
|
|
{
|
|
$outputs = [];
|
|
$bundle_dir = storage_path('rsx-build/bundles');
|
|
|
|
if (!is_dir($bundle_dir)) {
|
|
mkdir($bundle_dir, 0755, true);
|
|
}
|
|
|
|
// Only compile types that need it
|
|
// If empty, nothing needs compiling - just return empty
|
|
if (empty($types_to_compile)) {
|
|
return $outputs;
|
|
}
|
|
|
|
// Compile requested types
|
|
foreach ($types_to_compile as $type) {
|
|
// Get the flat array of ALL files for this type
|
|
$all_files = $this->bundle_files[$type] ?? [];
|
|
|
|
// NOW we finally organize by extension for output
|
|
// This is the FIRST and ONLY time we care about extensions
|
|
$files = ['js' => [], 'css' => []];
|
|
|
|
foreach ($all_files as $file) {
|
|
$ext = pathinfo($file, PATHINFO_EXTENSION);
|
|
if ($ext === 'js') {
|
|
$files['js'][] = $file;
|
|
} elseif ($ext === 'css') {
|
|
$files['css'][] = $file;
|
|
}
|
|
// Other extensions are ignored - they should have been processed into JS/CSS
|
|
}
|
|
|
|
// Order JavaScript files by class dependency BEFORE adding framework code
|
|
if (!empty($files['js'])) {
|
|
$files['js'] = $this->_order_javascript_files_by_dependency($files['js']);
|
|
}
|
|
|
|
// Use the cache key hash for filenames
|
|
$hash = isset($this->cache_keys[$type]) ? substr($this->cache_keys[$type], 0, 8) : substr(md5($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 JS stubs and framework code to app JS
|
|
if ($type === 'app') {
|
|
// Add NPM import declarations at the very beginning
|
|
if (!empty($this->npm_includes)) {
|
|
$npm_import_file = $this->_generate_npm_import_declarations();
|
|
if ($npm_import_file) {
|
|
array_unshift($files['js'], $npm_import_file);
|
|
}
|
|
}
|
|
|
|
// ALWAYS get JS stubs for ALL included files that have them
|
|
// ANY file type can have a js_stub - controllers, models, custom types, etc.
|
|
$stub_files = $this->_get_js_stubs();
|
|
foreach ($stub_files as $stub) {
|
|
$files['js'][] = $stub;
|
|
}
|
|
|
|
// Add compiled jqhtml files AFTER JS stubs
|
|
// These are JavaScript files generated from .jqhtml templates
|
|
foreach ($this->jqhtml_compiled_files as $jqhtml_file) {
|
|
$files['js'][] = $jqhtml_file;
|
|
}
|
|
|
|
// Generate manifest definitions for all JS classes
|
|
$manifest_file = $this->_create_javascript_manifest($files['js'] ?? []);
|
|
if ($manifest_file) {
|
|
$files['js'][] = $manifest_file;
|
|
}
|
|
|
|
// Generate route definitions for JavaScript
|
|
$route_file = $this->_create_javascript_routes();
|
|
if ($route_file) {
|
|
$files['js'][] = $route_file;
|
|
}
|
|
|
|
// Generate initialization runner
|
|
$runner_file = $this->_create_javascript_runner();
|
|
if ($runner_file) {
|
|
$files['js'][] = $runner_file;
|
|
}
|
|
}
|
|
|
|
// Compile JS
|
|
if (!empty($files['js'])) {
|
|
$js_content = $this->_compile_js_files($files['js']);
|
|
$js_file = "{$this->bundle_name}__{$type}.{$hash}.js";
|
|
file_put_contents("{$bundle_dir}/{$js_file}", $js_content);
|
|
$outputs["{$type}_js"] = $js_file;
|
|
}
|
|
|
|
// Compile CSS
|
|
if (!empty($files['css'])) {
|
|
$css_content = $this->_compile_css_files($files['css']);
|
|
$css_file = "{$this->bundle_name}__{$type}.{$hash}.css";
|
|
file_put_contents("{$bundle_dir}/{$css_file}", $css_content);
|
|
$outputs["{$type}_css"] = $css_file;
|
|
}
|
|
}
|
|
|
|
return $outputs;
|
|
}
|
|
|
|
/**
|
|
* Parse an import statement string into components
|
|
*
|
|
* Examples:
|
|
* "import { Component } from '@jqhtml/core'"
|
|
* "import * as React from 'react'"
|
|
* "import lodash from 'lodash'"
|
|
*
|
|
* @throws RuntimeException if statement is invalid
|
|
*/
|
|
protected function _parse_import_statement(string $statement): array
|
|
{
|
|
$statement = trim($statement);
|
|
|
|
// Must start with "import " and end with single or double quote
|
|
if (!str_starts_with($statement, 'import ')) {
|
|
throw new RuntimeException("Invalid import statement - must start with 'import ': {$statement}");
|
|
}
|
|
|
|
if (!str_ends_with($statement, "'") && !str_ends_with($statement, '"')) {
|
|
throw new RuntimeException("Invalid import statement - must end with quote: {$statement}");
|
|
}
|
|
|
|
// Extract the package name (everything between quotes)
|
|
if (preg_match("/from\\s+['\"]([^'\"]+)['\"]\\s*$/", $statement, $matches)) {
|
|
$package_name = $matches[1];
|
|
} else {
|
|
throw new RuntimeException("Invalid import statement - cannot extract package: {$statement}");
|
|
}
|
|
|
|
// Extract what's being imported (between "import" and "from")
|
|
$import_part = trim(substr($statement, 7)); // Remove "import "
|
|
$from_pos = strrpos($import_part, ' from ');
|
|
if ($from_pos === false) {
|
|
throw new RuntimeException("Invalid import statement - missing 'from': {$statement}");
|
|
}
|
|
$import_spec = trim(substr($import_part, 0, $from_pos));
|
|
|
|
// Determine the import type and extract names
|
|
if (str_starts_with($import_spec, '{') && str_ends_with($import_spec, '}')) {
|
|
// Named imports: { Component, Something }
|
|
$type = 'named';
|
|
$names = trim(substr($import_spec, 1, -1));
|
|
} elseif (str_starts_with($import_spec, '* as ')) {
|
|
// Namespace import: * as Something
|
|
$type = 'namespace';
|
|
$names = trim(substr($import_spec, 5));
|
|
} else {
|
|
// Default import: Something
|
|
$type = 'default';
|
|
$names = $import_spec;
|
|
}
|
|
|
|
return [
|
|
'statement' => $statement,
|
|
'package' => $package_name,
|
|
'type' => $type,
|
|
'imports' => $names,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Generate NPM includes using esbuild compilation
|
|
* Returns the path to the generated bundle file, not the content
|
|
*/
|
|
protected function _generate_npm_includes(): string
|
|
{
|
|
if (empty($this->npm_includes)) {
|
|
return '';
|
|
}
|
|
|
|
// Parse all import statements
|
|
$parsed_imports = [];
|
|
foreach ($this->npm_includes as $global_name => $import_statement) {
|
|
try {
|
|
$parsed = $this->_parse_import_statement($import_statement);
|
|
$parsed['global_name'] = $global_name;
|
|
$parsed_imports[] = $parsed;
|
|
} catch (RuntimeException $e) {
|
|
throw new RuntimeException(
|
|
"Invalid NPM import for '{$global_name}': " . $e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
|
|
// Collect all package-lock.json files for cache key
|
|
$package_lock_contents = [];
|
|
$lock_files = [
|
|
'package-lock.json',
|
|
'internal-libs/jqhtml/package-lock.json',
|
|
'internal-libs/rsx-scss-lint/package-lock.json',
|
|
];
|
|
|
|
foreach ($lock_files as $lock_file) {
|
|
$full_path = base_path($lock_file);
|
|
if (file_exists($full_path)) {
|
|
$package_lock_contents[$lock_file] = md5_file($full_path);
|
|
}
|
|
}
|
|
|
|
// Generate cache key from:
|
|
// 1. All package-lock.json file hashes
|
|
// 2. The npm array itself (sorted for consistency)
|
|
// 3. The current working directory
|
|
$cache_components = [
|
|
'package_locks' => $package_lock_contents,
|
|
'npm_array' => $this->npm_includes,
|
|
'cwd' => getcwd(),
|
|
];
|
|
$cache_key = md5(serialize($cache_components));
|
|
|
|
// Generate bundle filename for this specific bundle
|
|
$bundle_filename = "npm_{$this->bundle_name}_{$cache_key}.js";
|
|
$bundle_path = storage_path("rsx-build/bundles/{$bundle_filename}");
|
|
|
|
// Check if bundle already exists
|
|
if (file_exists($bundle_path)) {
|
|
console_debug('BUNDLE', "Using cached NPM bundle: {$bundle_filename}");
|
|
|
|
return $bundle_path;
|
|
}
|
|
|
|
console_debug('BUNDLE', "Compiling NPM modules for {$this->bundle_name}");
|
|
|
|
// Create storage directory if needed
|
|
$bundle_dir = dirname($bundle_path);
|
|
if (!is_dir($bundle_dir)) {
|
|
mkdir($bundle_dir, 0755, true);
|
|
}
|
|
|
|
// Generate entry file content with import statements
|
|
$entry_content = "// Auto-generated NPM module exports for {$this->bundle_name}\n";
|
|
$entry_content .= "// Cache key: {$cache_key}\n\n";
|
|
|
|
// Initialize the _rsx_npm object
|
|
$entry_content .= "// Initialize RSX NPM module container\n";
|
|
$entry_content .= "window._rsx_npm = window._rsx_npm || {};\n\n";
|
|
|
|
foreach ($parsed_imports as $import) {
|
|
$global_name = $import['global_name'];
|
|
$statement = $import['statement'];
|
|
|
|
// Add the original import statement
|
|
$entry_content .= "{$statement}\n";
|
|
|
|
// Add window._rsx_npm assignment based on import type
|
|
switch ($import['type']) {
|
|
case 'named':
|
|
// For named imports, handle single vs multiple differently
|
|
$names = explode(',', $import['imports']);
|
|
$exports = [];
|
|
foreach ($names as $name) {
|
|
$name = trim($name);
|
|
// Handle "name as alias" syntax
|
|
if (str_contains($name, ' as ')) {
|
|
[$original, $alias] = explode(' as ', $name);
|
|
$exports[] = trim($alias);
|
|
} else {
|
|
$exports[] = $name;
|
|
}
|
|
}
|
|
|
|
// If there's only one named import, assign it directly (for class constructors)
|
|
// Otherwise create an object with the named exports
|
|
if (count($exports) === 1) {
|
|
$entry_content .= "window._rsx_npm.{$global_name} = {$exports[0]};\n";
|
|
} else {
|
|
$entry_content .= "window._rsx_npm.{$global_name} = { " . implode(', ', $exports) . " };\n";
|
|
}
|
|
break;
|
|
|
|
case 'namespace':
|
|
// For namespace imports, assign directly
|
|
$import_name = $import['imports'];
|
|
$entry_content .= "window._rsx_npm.{$global_name} = {$import_name};\n";
|
|
break;
|
|
|
|
case 'default':
|
|
// For default imports, assign directly
|
|
$import_name = $import['imports'];
|
|
$entry_content .= "window._rsx_npm.{$global_name} = {$import_name};\n";
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Write entry file to temp location
|
|
$temp_dir = storage_path('rsx-tmp/npm-compile');
|
|
if (!is_dir($temp_dir)) {
|
|
mkdir($temp_dir, 0755, true);
|
|
}
|
|
$entry_file = "{$temp_dir}/entry_{$cache_key}.js";
|
|
file_put_contents($entry_file, $entry_content);
|
|
|
|
// Use esbuild for bundling
|
|
$esbuild_bin = base_path('node_modules/.bin/esbuild');
|
|
if (!file_exists($esbuild_bin)) {
|
|
throw new RuntimeException('esbuild not found. Please run: npm install --save-dev esbuild');
|
|
}
|
|
|
|
// Set working directory to project root for proper module resolution
|
|
$original_cwd = getcwd();
|
|
chdir(base_path());
|
|
|
|
$esbuild_cmd = sprintf(
|
|
'%s %s --bundle --format=iife --target=es2020 --outfile=%s 2>&1',
|
|
escapeshellarg($esbuild_bin),
|
|
escapeshellarg($entry_file),
|
|
escapeshellarg($bundle_path)
|
|
);
|
|
|
|
$output = [];
|
|
$return_var = 0;
|
|
\exec_safe($esbuild_cmd, $output, $return_var);
|
|
|
|
// Restore working directory
|
|
chdir($original_cwd);
|
|
|
|
if ($return_var !== 0) {
|
|
console_debug('BUNDLE', 'ESBuild failed: ' . implode("\n", $output));
|
|
// Clean up temp file on error
|
|
@unlink($entry_file);
|
|
|
|
throw new RuntimeException('Failed to compile NPM modules: ' . implode("\n", $output));
|
|
}
|
|
|
|
// Clean up temp file
|
|
@unlink($entry_file);
|
|
|
|
// Verify bundle was created
|
|
if (!file_exists($bundle_path)) {
|
|
throw new RuntimeException("ESBuild did not produce expected output file: {$bundle_path}");
|
|
}
|
|
|
|
$size = filesize($bundle_path);
|
|
console_debug('BUNDLE', "NPM bundle compiled: {$bundle_filename} ({$size} bytes)");
|
|
|
|
return $bundle_path;
|
|
}
|
|
|
|
/**
|
|
* Generate NPM import declarations file for app bundle
|
|
* This creates const declarations that pull from window._rsx_npm
|
|
*/
|
|
protected function _generate_npm_import_declarations(): string
|
|
{
|
|
if (empty($this->npm_includes)) {
|
|
return '';
|
|
}
|
|
|
|
// Generate cache key from NPM includes
|
|
$cache_key = md5(serialize($this->npm_includes));
|
|
$filename = "npm_import_declarations_{$cache_key}.js";
|
|
$file_path = storage_path("rsx-tmp/{$filename}");
|
|
|
|
// Check if already generated
|
|
if (file_exists($file_path)) {
|
|
return $file_path;
|
|
}
|
|
|
|
// Generate content
|
|
$content = "// NPM Import Declarations for App Bundle\n";
|
|
$content .= "// Auto-generated to provide NPM modules to app bundle scope\n";
|
|
$content .= "// Cache key: {$cache_key}\n\n";
|
|
|
|
foreach ($this->npm_includes as $global_name => $import_statement) {
|
|
// Generate const declaration
|
|
$content .= "const {$global_name} = window._rsx_npm.{$global_name};\n";
|
|
$content .= "if (!{$global_name}) {\n";
|
|
$content .= " throw new Error(\n";
|
|
$content .= " 'RSX Framework Error: NPM module \"{$global_name}\" not found.\\n' +\n";
|
|
$content .= " 'Expected window._rsx_npm.{$global_name} to be defined by the vendor bundle.'\n";
|
|
$content .= " );\n";
|
|
$content .= "}\n\n";
|
|
}
|
|
|
|
// Clean up window._rsx_npm to prevent console access
|
|
$content .= "// Clean up NPM container to prevent console access\n";
|
|
$content .= "delete window._rsx_npm;\n";
|
|
|
|
// Write file
|
|
file_put_contents($file_path, $content);
|
|
|
|
return $file_path;
|
|
}
|
|
|
|
/**
|
|
* Compile JS files
|
|
*/
|
|
protected function _compile_js_files(array $files): string
|
|
{
|
|
// If we have config, write it to a temp file first
|
|
$files_to_concat = [];
|
|
|
|
if (!empty($this->config)) {
|
|
$config_content = ['window.rsxapp = window.rsxapp || {};'];
|
|
foreach ($this->config as $key => $value) {
|
|
$config_content[] = "window.rsxapp.{$key} = " . json_encode($value) . ';';
|
|
}
|
|
|
|
// Write config to temp file
|
|
$config_file = storage_path('rsx-tmp/bundle_config_' . $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 and already processed files
|
|
if (str_contains($file, 'storage/rsx-tmp/') || str_contains($file, 'storage/rsx-build/')) {
|
|
continue;
|
|
}
|
|
|
|
// Transform the file (will use cache if available)
|
|
try {
|
|
$transformed_code = \App\RSpade\Core\JavaScript\Js_Transformer::transform($file);
|
|
|
|
// Write transformed code to a temp file
|
|
$temp_file = storage_path('rsx-tmp/babel_' . md5($file) . '.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);
|
|
|
|
// Minify in production (TODO: preserve source maps when minifying)
|
|
if ($this->is_production) {
|
|
// TODO: Add minification that preserves source maps
|
|
}
|
|
|
|
return $js;
|
|
}
|
|
|
|
/**
|
|
* Compile CSS files with sourcemap support
|
|
*/
|
|
protected function _compile_css_files(array $files): string
|
|
{
|
|
// Use Node.js script to concatenate with source map support
|
|
$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);
|
|
|
|
// Minify in production
|
|
if ($this->is_production) {
|
|
// TODO: Add minification that preserves source maps
|
|
}
|
|
|
|
return $css;
|
|
}
|
|
|
|
/**
|
|
* Create JavaScript manifest definitions
|
|
*/
|
|
protected function _create_javascript_manifest(array $js_files): ?string
|
|
{
|
|
$class_definitions = [];
|
|
$manifest_files = Manifest::get_all();
|
|
|
|
// Analyze each JavaScript file for class information
|
|
foreach ($js_files as $file) {
|
|
// Skip temp files
|
|
if (str_contains($file, 'storage/rsx-tmp/')) {
|
|
continue;
|
|
}
|
|
|
|
// Get relative path for manifest lookup
|
|
$relative = str_replace(base_path() . '/', '', $file);
|
|
|
|
// Get file data from manifest
|
|
$file_data = $manifest_files[$relative] ?? null;
|
|
|
|
// 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;
|
|
}
|
|
}
|