Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
<?php
namespace App\RSpade\Core\Bundle;
/**
* Bundle_Integration_Abstract - Base class for external integrations into the RSX framework
*
* This abstract class defines the contract for integrations (plugins) that extend
* the framework's capabilities. Each integration can provide:
* - File type handlers for the Manifest system
* - Asset modules for the Bundle system
* - File processors for transformation during bundling
* - Custom file extensions to be discovered
*
* EXAMPLE IMPLEMENTATIONS:
* - Jqhtml_BundleIntegration: Adds .jqhtml template support
* - TypeScriptIntegration: Adds TypeScript compilation
* - VueIntegration: Adds .vue single-file component support
*
* REGISTRATION:
* Integrations are registered via service providers that call the appropriate
* registration methods on ExtensionRegistry, BundleCompiler, etc.
*/
abstract class BundleIntegration_Abstract
{
/**
* Get the integration's unique identifier
*
* This should be a short, lowercase string that uniquely identifies
* the integration (e.g., 'jqhtml', 'typescript', 'vue')
*
* @return string Integration identifier
*/
abstract public static function get_name(): string;
/**
* Get file extensions handled by this integration
*
* Return an array of file extensions (without dots) that this
* integration handles. These will be registered with the ExtensionRegistry
* for discovery and processing.
*
* @return array File extensions (e.g., ['jqhtml', 'jqtpl'])
*/
abstract public static function get_file_extensions(): array;
/**
* Get the ManifestModule class for file discovery (default: null)
*
* Return the fully qualified class name of a ManifestModule that
* handles file discovery and metadata extraction for this integration's
* file types. Return null if no manifest processing is needed.
*
* @return string|null ManifestModule class name or null
*/
public static function get_manifest_module(): ?string
{
return null;
}
/**
* @deprecated BundleModule system is obsolete - use BundleProcessor instead
*
* This method is retained for backwards compatibility but always returns null.
* The BundleModule system has been replaced by BundleProcessor for all
* compilation and asset handling needs.
*
* @return null Always returns null
*/
public static function get_bundle_module(): ?string
{
return null; // BundleModule system is obsolete
}
/**
* Get the Processor class for file transformation (default: null)
*
* Return the fully qualified class name of a BundleProcessor that
* transforms this integration's file types during bundle compilation.
* Return null if no processing is needed.
*
* @return string|null BundleProcessor class name or null
*/
public static function get_processor(): ?string
{
return null;
}
/**
* Get configuration options for this integration (default: empty array)
*
* Return an array of configuration options that can be customized
* in config files or at runtime. These might include compiler options,
* feature flags, or other settings.
*
* @return array Configuration options with defaults
*/
public static function get_config(): array
{
return [];
}
/**
* Get priority for this integration (default: 1000)
*
* Lower values mean higher priority. This affects the order in which
* integrations are loaded and their processors are run.
*
* @return int Priority (default 1000)
*/
public static function get_priority(): int
{
return 1000;
}
/**
* Check if this integration is enabled (default: true)
*
* Return whether this integration should be active. This might check
* configuration settings, environment variables, or other conditions.
*
* @return bool True if enabled
*/
public static function is_enabled(): bool
{
return true;
}
/**
* Bootstrap the integration (default: no-op)
*
* Called when the integration is registered. Use this to perform any
* one-time setup, register additional services, or configure the environment.
* This is called after all core services are available.
*/
public static function bootstrap(): void
{
// Override in subclasses if needed
}
/**
* Get dependencies for this integration (default: empty array)
*
* Return an array of other integration names that must be loaded
* before this one. The framework will ensure proper loading order.
*
* @return array Integration names this depends on
*/
public static function get_dependencies(): array
{
return [];
}
/**
* Generate JavaScript stub files for manifest entries (default: no-op)
*
* Called during manifest building (Phase 5) to generate JavaScript
* stub files that provide IDE autocomplete and runtime functionality
* for PHP classes. Integrations can use this to create JS equivalents
* of controllers, models, or other PHP classes.
*
* @param array &$manifest_data The complete manifest data (passed by reference)
* @return void
*/
public static function generate_manifest_stubs(array &$manifest_data): void
{
// Override in subclasses to generate stubs
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\RSpade\Core\Bundle;
/**
* BundleProcessor_Abstract - Base class for file transformation processors
*
* Processors handle compilation and transformation of specific file types
* during bundle building. Examples include:
* - JQHTML templates JavaScript
* - SCSS/SASS CSS
* - TypeScript JavaScript
* - ES6+ ES5 (transpilation)
*
* SEPARATION OF CONCERNS:
* - Processors focus solely on file transformation
* - Processors modify bundle file arrays by reference
* - Bundles orchestrate the overall compilation
*
* STATIC DESIGN:
* All methods are static - processors are stateless transformation engines
*
* BY-REFERENCE PROCESSING:
* The process_batch() method receives the bundle files array by reference
* and modifies it directly by appending compiled output files
*
* EXAMPLE:
* class Jqhtml_BundleProcessor extends BundleProcessor_Abstract {
* public static function process_batch(array &$bundle_files): void {
* foreach ($bundle_files as $path) {
* if (pathinfo($path, PATHINFO_EXTENSION) === 'jqhtml') {
* $compiled = static::compile_template($path);
* $temp_file = static::_write_temp_file($compiled['js_code'], 'js');
* $bundle_files[] = $temp_file;
* }
* }
* }
* }
*/
abstract class BundleProcessor_Abstract
{
/**
* Get the processor's unique identifier
*
* @return string Processor name (e.g., 'jqhtml', 'less', 'typescript')
*/
abstract public static function get_name(): string;
/**
* Get file extensions this processor handles
*
* @return array Extensions without dots (e.g., ['jqhtml', 'jqtpl'])
*/
abstract public static function get_extensions(): array;
/**
* Process multiple files in batch
*
* Examines all files in the bundle and compiles those matching this processor's extensions.
* Compiled output files are appended to the bundle_files array.
* Uses file modification time caching - skips compilation if temp file is newer than source.
*
* @param array &$bundle_files Array of file paths (modified by reference)
* @return void
*/
abstract public static function process_batch(array &$bundle_files): void;
/**
* Pre-processing hook (default: no-op)
*
* Called before any files are processed. Can be used to initialize
* resources, validate configuration, or prepare the processing environment.
*
* @param array $all_files All files that will be processed
* @param array $options Processing options
*/
public static function before_processing(array $all_files, array $options = []): void
{
// Override in subclasses if needed
}
/**
* Post-processing hook (default: no additional files)
*
* Called after all files are processed. Can be used to generate
* additional output, clean up resources, or perform final transformations.
*
* @param array $processed_files Processed file results
* @param array $options Processing options
* @return array Additional files to include in bundle
*/
public static function after_processing(array $processed_files, array $options = []): array
{
return [];
}
/**
* Get processor configuration
*
* @return array Processor configuration from config/rsx.php
*/
public static function get_config(): array
{
$name = static::get_name();
return config("rsx.processors.{$name}", []);
}
/**
* Validate processor configuration (default: no validation)
*
* @throws \RuntimeException If configuration is invalid
*/
public static function validate(): void
{
// Override in subclasses to add validation
}
/**
* Get processor priority (default: 500)
*
* Processors with lower priority run first.
* This allows dependencies between processors.
*
* @return int Priority (0-1000, default 500)
*/
public static function get_priority(): int
{
$config = static::get_config();
return $config['priority'] ?? 500;
}
/**
* Get metadata for manifest integration (default: basic metadata)
*
* Return metadata that should be added to the manifest
* for files processed by this processor.
*
* @param string $file_path File being processed
* @param array $result Processing result
* @return array Metadata for manifest
*/
public static function get_manifest_metadata(string $file_path, array $result): array
{
return [
'processor' => static::get_name(),
'processed_at' => time(),
'type' => $result['type'] ?? 'unknown'
];
}
/**
* Helper: Read file contents safely
*/
protected static function _read_file(string $path): string
{
if (!file_exists($path)) {
throw new \RuntimeException("File not found: {$path}");
}
return file_get_contents($path);
}
/**
* Helper: Write to temp file
*/
protected static function _write_temp_file(string $content, string $extension = 'tmp'): string
{
$temp_dir = storage_path('rsx-tmp');
if (!is_dir($temp_dir)) {
mkdir($temp_dir, 0755, true);
}
$temp_file = $temp_dir . '/' . uniqid('processor_') . '.' . $extension;
file_put_contents($temp_file, $content);
return $temp_file;
}
/**
* Helper: Get cache key for processed file
*/
protected static function _get_cache_key(string $file_path): string
{
$content = file_get_contents($file_path);
$mtime = filemtime($file_path);
return md5($file_path . ':' . $mtime . ':' . $content);
}
}

View File

@@ -0,0 +1,39 @@
# Bundle Processor System
## Architecture
- **Extension-agnostic BundleCompiler**: Only handles .js and .css files directly
- **Global processor configuration**: Processors configured in `config/rsx.php` under `bundle_processors`
- **Processors receive ALL files**: Each processor examines all collected files and decides what to process
- **No per-bundle processor config**: Processors are globally enabled, not per-bundle
## How It Works
1. BundleCompiler collects ALL files from bundle includes (not just JS/CSS)
2. Each configured processor receives the full list of collected files
3. Processors decide which files to process based on extension or other criteria
4. Processed files replace or augment original files in the bundle
5. Final bundle contains JS and CSS files only
## Creating a Processor
```php
class MyProcessor extends AbstractBundleProcessor {
public static function get_name(): string { return 'myprocessor'; }
public static function get_extensions(): array { return ['myext']; }
public static function process(string $file_path, array $options = []): ?array {
// Transform file and return result or null to exclude
}
}
```
## Registering Processors
Add to `config/rsx.php`:
```php
'bundle_processors' => [
\App\RSpade\Processors\MyProcessor::class,
]
```
## SCSS @import Validation
- **Non-vendor files**: Cannot use @import directives (throws error)
- **Vendor files**: Files in directories named 'vendor' CAN use @import
- **Rationale**: @import bypasses bundle dependency management
- **Solution**: Include dependencies directly in bundle definition

View File

@@ -0,0 +1,29 @@
<?php
namespace App\RSpade\Core\Bundle;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Core Framework Bundle
*
* Provides all core JavaScript framework files that are required for RSX to function.
* This bundle is automatically included in every project.
*/
class Core_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [
__DIR__,
'app/RSpade/Core/Js',
],
];
}
}

View File

@@ -0,0 +1,701 @@
<?php
namespace App\RSpade\Core\Bundle;
use RuntimeException;
use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors;
use App\RSpade\Core\Bundle\BundleCompiler;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Session\Session;
/**
* Rsx_Bundle_Abstract - Abstract base class for user-defined bundles
*
* Bundles are high-level asset collections that define what JavaScript, CSS,
* and other resources should be included in a page. The bundle system uses a
* unified 'include' array that automatically detects what each item is:
* - Module aliases (jquery, lodash, bootstrap5)
* - Bundle aliases (defined in config/rsx.php)
* - Bundle class names (e.g., Frontend_Bundle)
* - Bundle file paths (e.g., rsx/theme/bootstrap5_src_bundle.php)
* - Directory paths (e.g., rsx/app/frontend)
* - Regular file paths (e.g., rsx/theme/variables.scss)
*
* USAGE:
* 1. Create a bundle class that extends Rsx_Bundle_Abstract
* 2. Implement the define() method with an 'include' array
* 3. Call BundleName::render() in Blade views
*
* EXAMPLE:
* class Frontend_Bundle extends Rsx_Bundle_Abstract {
* public static function define(): array {
* return [
* 'include' => [
* 'jquery', // Module alias
* 'bootstrap5_src', // Bundle alias
* Common_Bundle::class, // Bundle class
* 'rsx/theme/variables.scss', // File
* 'rsx/app/frontend', // Directory
* ],
* ];
* }
* }
*/
abstract class Rsx_Bundle_Abstract
{
/**
* Define the bundle's assets
*
* Return an array with the following keys:
* - include: Array of items to include (auto-detected types)
* - config: Bundle-specific configuration (optional)
* - npm_modules: NPM modules to include (optional)
* - cdn_assets: Direct CDN assets (optional)
* - local_assets: Local assets not bundled (optional)
*
* The 'include' array accepts:
* - Module aliases: 'jquery', 'lodash', 'bootstrap5', 'jqhtml'
* - Bundle aliases: Defined in config/rsx.php bundle_aliases
* - Bundle classes: MyBundle::class or 'MyBundle'
* - Bundle files: 'path/to/bundle.php'
* - Directories: 'rsx/app/mymodule'
* - Files: 'rsx/theme/style.scss'
*
* @return array Bundle definition
*/
abstract public static function define(): array;
/**
* Track which bundle has been rendered for this request
* Format: ['bundle_class' => 'Bundle_Name', 'calling_location' => 'file:line']
*/
public static ?array $_has_rendered = null;
/**
* Render a bundle's HTML output
*
* @param array $options Rendering options
* @return string HTML output with script/link tags
*/
public static function render(array $options = []): string
{
// Get the calling class
$bundle_class = get_called_class();
// Fatal if called directly on Rsx_Bundle_Abstract
if ($bundle_class === __CLASS__ || $bundle_class === 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
// Cannot call abstract class directly
BundleErrors::abstract_called_directly();
}
// Check if another bundle has already been rendered
if (Rsx_Bundle_Abstract::$_has_rendered !== null) {
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
$caller = $backtrace[0] ?? [];
$current_location = ($caller['file'] ?? 'unknown') . ':' . ($caller['line'] ?? '?');
throw new RuntimeException(
"Multiple bundle render attempted.\n\n" .
'Already rendered: ' . Rsx_Bundle_Abstract::$_has_rendered['bundle_class'] . "\n" .
'Called from: ' . Rsx_Bundle_Abstract::$_has_rendered['calling_location'] . "\n\n" .
'Attempted to render: ' . $bundle_class . "\n" .
'Called from: ' . $current_location . "\n\n" .
"Only one bundle is allowed per page. If you need to share code between bundles,\n" .
"include the shared bundle in your bundle's configuration:\n\n" .
"class Your_Bundle extends Rsx_Bundle_Abstract {\n" .
" public static function define(): array {\n" .
" return [\n" .
" 'include' => [\n" .
" Shared_Bundle::class, // Include another bundle\n" .
" 'your/specific/files',\n" .
" ]\n" .
" ];\n" .
" }\n" .
'}'
);
}
// Track this bundle render
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
$caller = $backtrace[0] ?? [];
Rsx_Bundle_Abstract::$_has_rendered = [
'bundle_class' => $bundle_class,
'calling_location' => ($caller['file'] ?? 'unknown') . ':' . ($caller['line'] ?? '?'),
];
// Dump any console debug messages before bundle output
\App\RSpade\Core\Debug\Debugger::dump_console_debug_messages_to_html();
// In development mode, validate path coverage
if (!app()->environment('production')) {
static::__validate_path_coverage($bundle_class);
}
$compiler = new BundleCompiler($options);
$compiled = $compiler->compile($bundle_class);
// Generate HTML output
return static::__generate_html($compiled, $options);
}
/**
* Get the bundle definition with resolved dependencies
*
* @param string $bundle_class Bundle class name
* @return array Resolved bundle definition
*/
public static function get_resolved_definition(string $bundle_class): array
{
// Get raw definition - verify class exists in Manifest
try {
// Check if this is a FQCN (contains backslash) or simple name
if (strpos($bundle_class, '\\') !== false) {
// It's a FQCN, use php_get_metadata_by_fqcn
$metadata = Manifest::php_get_metadata_by_fqcn($bundle_class);
} else {
// It's a simple name, use php_get_metadata_by_class
$metadata = Manifest::php_get_metadata_by_class($bundle_class);
}
if (!isset($metadata['extends']) || ($metadata['extends'] !== 'Rsx_Bundle_Abstract' && $metadata['extends'] !== 'RsxBundle')) {
// Check if it extends a class that extends Rsx_Bundle_Abstract
$extends_bundle = false;
$current_extends = $metadata['extends'] ?? null;
while ($current_extends && !$extends_bundle) {
if ($current_extends === 'Rsx_Bundle_Abstract' || $current_extends === 'RsxBundle') {
$extends_bundle = true;
} else {
try {
$parent_metadata = Manifest::php_get_metadata_by_class($current_extends);
$current_extends = $parent_metadata['extends'] ?? null;
} catch (RuntimeException $e) {
break;
}
}
}
if (!$extends_bundle) {
throw new RuntimeException("Class {$bundle_class} must extend Rsx_Bundle_Abstract");
}
}
} catch (RuntimeException $e) {
if (str_contains($e->getMessage(), 'not found in manifest')) {
throw new RuntimeException("Bundle class not found: {$bundle_class}");
}
throw $e;
}
$definition = $bundle_class::define();
// Base config that all bundles inherit
$base_config = [
'debug' => config('app.debug'),
'build_key' => Manifest::get_build_key(),
];
// Start with base config
$config = $base_config;
// Merge in bundle-specific config using array_merge_deep
if (!empty($definition['config'])) {
$config = array_merge_deep($config, $definition['config']);
}
// Normalize definition structure - simplified with unified 'include' array
$resolved = [
'include' => $definition['include'] ?? [],
'npm_modules' => $definition['npm_modules'] ?? [], // NPM modules support
'config' => $config,
'cdn_assets' => $definition['cdn_assets'] ?? [],
'local_assets' => $definition['local_assets'] ?? [],
];
return $resolved;
}
/**
* Generate HTML output for compiled bundle
*
* @param array $compiled Compiled bundle data
* @param array $options Rendering options
* @return string HTML output
*/
protected static function __generate_html(array $compiled, array $options = []): string
{
// Validate we have the expected structure
if (!is_array($compiled)) {
throw new RuntimeException('BundleCompiler returned non-array: ' . gettype($compiled));
}
// In development, we should have vendor/app split files
// In production, we should have combined files
$is_production = app()->environment('production');
if (!$is_production) {
// Development mode - expect vendor/app bundle paths
if (!isset($compiled['vendor_js_bundle_path']) &&
!isset($compiled['app_js_bundle_path']) &&
!isset($compiled['vendor_css_bundle_path']) &&
!isset($compiled['app_css_bundle_path'])) {
throw new RuntimeException('BundleCompiler missing expected vendor/app bundle paths. Got keys: ' . implode(', ', array_keys($compiled)));
}
} else {
// Production mode - expect combined bundle paths
if (!isset($compiled['js_bundle_path']) && !isset($compiled['css_bundle_path'])) {
throw new RuntimeException('BundleCompiler missing expected js/css bundle paths. Got keys: ' . implode(', ', array_keys($compiled)));
}
}
$html = [];
$manifest_hash = Manifest::get_build_key();
// Consolidate all data into window.rsxapp
$rsxapp_data = [];
// Add build_key (always included in both dev and production)
$rsxapp_data['build_key'] = $manifest_hash;
// Add bundle data if present
if (!empty($compiled['config'])) {
$rsxapp_data = array_merge($rsxapp_data, $compiled['bundle_data'] ?? []);
}
// Add runtime data
$rsxapp_data['debug'] = !app()->environment('production');
$rsxapp_data['current_controller'] = \App\RSpade\Core\Rsx::get_current_controller();
$rsxapp_data['current_action'] = \App\RSpade\Core\Rsx::get_current_action();
$rsxapp_data['is_auth'] = Session::is_logged_in();
$rsxapp_data['ajax_disable_batching'] = config('rsx.development.ajax_disable_batching', false);
// Add user, site, and csrf data from session
$rsxapp_data['user'] = Session::get_user();
$rsxapp_data['site'] = Session::get_site();
$rsxapp_data['csrf'] = Session::get_csrf_token();
// Add browser error logging flag (enabled in both dev and production)
if (config('rsx.log_browser_errors', false)) {
$rsxapp_data['log_browser_errors'] = true;
}
// Add console_debug config in non-production mode
if (!app()->environment('production')) {
$console_debug_config = config('rsx.console_debug', []);
// Build console_debug settings
$filter_mode = $console_debug_config['filter_mode'] ?? 'all';
// Get the appropriate channel list based on filter mode
$filter_channels = [];
if ($filter_mode === 'whitelist') {
$filter_channels = $console_debug_config['whitelist'] ?? [];
} elseif ($filter_mode === 'blacklist') {
$filter_channels = $console_debug_config['blacklist'] ?? [];
}
$console_debug = [
'enabled' => $console_debug_config['enabled'] ?? true,
'filter_mode' => $filter_mode,
'filter_channels' => $filter_channels,
'specific_channel' => $console_debug_config['specific_channel'] ?? null,
'include_timestamp' => $console_debug_config['include_timestamp'] ?? false,
'include_benchmark' => $console_debug_config['include_benchmark'] ?? false,
'include_location' => $console_debug_config['include_location'] ?? false,
'include_backtrace' => $console_debug_config['include_backtrace'] ?? false,
'outputs' => [
'browser' => ($console_debug_config['outputs']['web'] ?? true),
'laravel_log' => ($console_debug_config['outputs']['laravel_log'] ?? false),
],
];
// Check for Playwright test headers that override console_debug settings
// Only from loopback IPs without proxy headers
$request = request();
if ($request && $request->hasHeader('X-Playwright-Test') && is_loopback_ip()) {
// Check for disable header first
if ($request->hasHeader('X-Console-Debug-Disable')) {
$console_debug['enabled'] = false;
}
// Check for console debug filter header
if ($request->hasHeader('X-Console-Debug-Filter')) {
$filter = $request->header('X-Console-Debug-Filter');
if ($filter) {
$console_debug['filter_mode'] = 'specific';
$console_debug['specific_channel'] = strtoupper($filter);
}
}
// Check for benchmark header
if ($request->hasHeader('X-Console-Debug-Benchmark')) {
$console_debug['include_benchmark'] = true;
}
// Check for all channels header
if ($request->hasHeader('X-Console-Debug-All')) {
$console_debug['filter_mode'] = 'all';
$console_debug['specific_channel'] = null;
}
}
$rsxapp_data['console_debug'] = $console_debug;
}
// Pretty print JSON in non-production environments
$rsxapp_json = app()->environment('production')
? json_encode($rsxapp_data, JSON_UNESCAPED_SLASHES)
: json_encode($rsxapp_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$html[] = '<script>window.rsxapp = ' . $rsxapp_json . ';</script>';
// Sort CDN assets: jQuery first, then others in deterministic order
$cdn_css = $compiled['cdn_css'] ?? [];
$cdn_js = $compiled['cdn_js'] ?? [];
// Separate jQuery assets from others
$jquery_css = [];
$other_css = [];
foreach ($cdn_css as $asset) {
if (stripos($asset['url'], 'jquery') !== false) {
$jquery_css[] = $asset;
} else {
$other_css[] = $asset;
}
}
$jquery_js = [];
$other_js = [];
foreach ($cdn_js as $asset) {
if (stripos($asset['url'], 'jquery') !== false) {
$jquery_js[] = $asset;
} else {
$other_js[] = $asset;
}
}
// Sort other assets by URL for deterministic ordering
usort($other_css, function ($a, $b) {
return strcmp($a['url'], $b['url']);
});
usort($other_js, function ($a, $b) {
return strcmp($a['url'], $b['url']);
});
// Add CSS: jQuery first, then others
foreach (array_merge($jquery_css, $other_css) as $asset) {
$tag = '<link rel="stylesheet" href="' . htmlspecialchars($asset['url']) . '"';
if (!empty($asset['integrity'])) {
$tag .= ' integrity="' . htmlspecialchars($asset['integrity']) . '"';
$tag .= ' crossorigin="anonymous"';
}
$tag .= '>';
$html[] = $tag;
}
// Add JS: jQuery first, then others
foreach (array_merge($jquery_js, $other_js) as $asset) {
$tag = '<script src="' . htmlspecialchars($asset['url']) . '" defer';
if (!empty($asset['integrity'])) {
$tag .= ' integrity="' . htmlspecialchars($asset['integrity']) . '"';
$tag .= ' crossorigin="anonymous"';
}
$tag .= '></script>';
$html[] = $tag;
}
// Add CSS bundles
// In development mode with split bundles, add vendor then app
if (!empty($compiled['vendor_css_bundle_path']) || !empty($compiled['app_css_bundle_path'])) {
// Split bundles mode
if (!empty($compiled['vendor_css_bundle_path'])) {
$vendor_url = static::__get_bundle_url($compiled['vendor_css_bundle_path']);
$html[] = '<link rel="stylesheet" href="' . htmlspecialchars($vendor_url) . '">';
}
if (!empty($compiled['app_css_bundle_path'])) {
$app_url = static::__get_bundle_url($compiled['app_css_bundle_path']);
$html[] = '<link rel="stylesheet" href="' . htmlspecialchars($app_url) . '">';
}
} elseif (!empty($compiled['css_bundle_path'])) {
// Single bundle mode (production)
$url = static::__get_bundle_url($compiled['css_bundle_path']);
$html[] = '<link rel="stylesheet" href="' . htmlspecialchars($url) . '">';
}
// Add JS bundles
// In development mode with split bundles, add vendor then app
if (!empty($compiled['vendor_js_bundle_path']) || !empty($compiled['app_js_bundle_path'])) {
// Split bundles mode - vendor MUST load before app
if (!empty($compiled['vendor_js_bundle_path'])) {
$vendor_url = static::__get_bundle_url($compiled['vendor_js_bundle_path']);
$html[] = '<script src="' . htmlspecialchars($vendor_url) . '" defer></script>';
}
if (!empty($compiled['app_js_bundle_path'])) {
$app_url = static::__get_bundle_url($compiled['app_js_bundle_path']);
$html[] = '<script src="' . htmlspecialchars($app_url) . '" defer></script>';
}
} elseif (!empty($compiled['js_bundle_path'])) {
// Single bundle mode (production)
$url = static::__get_bundle_url($compiled['js_bundle_path']);
$html[] = '<script src="' . htmlspecialchars($url) . '" defer></script>';
}
// Inline JS removed - bundles handle their own initialization
return implode("\n", $html);
}
/**
* Get URL for a bundle file
*
* @param string $path Bundle file path
* @return string Bundle URL
*/
protected static function __get_bundle_url(string $path): string
{
// Extract filename from path
$filename = basename($path);
// Validate it's a compiled bundle file
// Format: BundleName__vendor.[hash].js/css or BundleName__app.[hash].js/css
if (preg_match('/^\w+__(vendor|app)\.[a-f0-9]{8}\.(js|css)$/', $filename)) {
// Use the controlled route for compiled assets
$url = '/_compiled/' . $filename;
// In development mode, add ?v= parameter for cache-busting
if (env('APP_ENV') !== 'production') {
$manifest_hash = Manifest::get_build_key();
$url .= '?v=' . $manifest_hash;
}
return $url;
}
// This shouldn't happen with properly compiled bundles
throw new RuntimeException("Invalid bundle file path: {$path}");
}
/**
* Resolve bundle class name from alias
*
* @param string $bundle Bundle class name or alias
* @return string Fully qualified bundle class name
*/
protected static function __resolve_bundle_class(string $bundle): string
{
// First try to find by exact class name in Manifest
try {
$metadata = Manifest::php_get_metadata_by_class($bundle);
return $metadata['fqcn'];
} catch (RuntimeException $e) {
// Not found by simple name
}
// Try with common namespace prefixes
$possible_names = [
$bundle,
"App\\Bundles\\{$bundle}",
"Rsx\\Bundles\\{$bundle}",
];
foreach ($possible_names as $name) {
try {
$metadata = Manifest::php_get_metadata_by_fqcn($name);
return $metadata['fqcn'];
} catch (RuntimeException $e) {
// Try next
}
}
// Search manifest for any class ending with the bundle name that extends Rsx_Bundle_Abstract
$manifest_data = Manifest::get_all();
foreach ($manifest_data as $file_info) {
if (isset($file_info['class']) &&
str_ends_with($file_info['class'], $bundle) &&
isset($file_info['extends']) &&
($file_info['extends'] === 'Rsx_Bundle_Abstract' || $file_info['extends'] === 'RsxBundle' ||
$file_info['extends'] === 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract' ||
$file_info['extends'] === 'App\\RSpade\\Core\\Bundle\\RsxBundle')) {
return $file_info['fqcn'];
}
}
throw new RuntimeException("Bundle class not found: {$bundle}");
}
/**
* Validate that the bundle's include paths cover the calling view/layout and controller
*
* @param string $bundle_class Fully qualified bundle class name
* @throws RuntimeException if bundle doesn't cover the calling file's directory or current controller
*/
protected static function __validate_path_coverage(string $bundle_class): void
{
// Try to get the view path from shared data (set by rsx_view helper)
$view = \Illuminate\Support\Facades\View::shared('rsx_current_view_path', null);
if (!$view) {
// View path required for bundle validation
BundleErrors::cannot_determine_view_path();
}
// Normalize the view path
$view_path = str_replace('\\', '/', $view);
if (!str_starts_with($view_path, '/')) {
// Convert to absolute path if needed
if (file_exists(base_path($view_path))) {
$view_path = base_path($view_path);
}
}
// Convert absolute path to relative from base_path
$base_path = str_replace('\\', '/', base_path());
if (str_starts_with($view_path, $base_path)) {
$view_path = substr($view_path, strlen($base_path) + 1);
}
// Get the bundle's definition to check include paths
$definition = static::get_resolved_definition($bundle_class);
$include_paths = $definition['include'] ?? [];
// If no include paths defined, skip validation (bundle might use explicit files)
if (empty($include_paths)) {
return;
}
// Convert any absolute paths in include_paths to relative
$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);
}
foreach ($include_paths as &$include_path) {
$include_path = str_replace('\\', '/', $include_path);
if (str_starts_with($include_path, '/')) {
// If it starts with base_path, strip it
if (str_starts_with($include_path, $base_path)) {
$include_path = substr($include_path, strlen($base_path) + 1);
} elseif (str_starts_with($include_path, $project_root)) {
// Path is under project root but not under base_path (e.g., symlinked rsx/)
$include_path = substr($include_path, strlen($project_root) + 1);
} else {
// If it's an absolute path not under project root, try removing leading slash
$include_path = ltrim($include_path, '/');
}
}
}
unset($include_path); // Clear reference
// Check if any include path covers the view's directory
$view_dir = dirname($view_path);
$is_covered = false;
foreach ($include_paths as $include_path) {
// Normalize path for comparison
$include_path = rtrim($include_path, '/');
// Check if the include path is a file that matches the view
if (str_ends_with($include_path, '.php') || str_ends_with($include_path, '.scss') ||
str_ends_with($include_path, '.css') || str_ends_with($include_path, '.js')) {
// For files, check if it matches the view file itself
if ($view_path === $include_path) {
$is_covered = true;
break;
}
continue;
}
// For directories, check if view directory starts with or equals the include path
if ($view_dir === $include_path || str_starts_with($view_dir, $include_path . '/')) {
$is_covered = true;
break;
}
}
// Throw error if not covered
if (!$is_covered) {
// Bundle doesn't include the view directory
BundleErrors::view_not_covered($view_path, $view_dir, $bundle_class, $include_paths);
}
// Check if current controller (if set during route dispatch) is covered by bundle
$current_controller = \App\RSpade\Core\Rsx::get_current_controller();
$current_action = \App\RSpade\Core\Rsx::get_current_action();
// Only validate if we're in a route dispatch context (controller and action are set)
if ($current_controller && $current_action) {
// Look up the controller file in the manifest
try {
$controller_metadata = Manifest::php_get_metadata_by_class($current_controller);
$controller_file = $controller_metadata['file'] ?? null;
if ($controller_file) {
// Normalize controller path
$controller_path = str_replace('\\', '/', $controller_file);
if (str_starts_with($controller_path, $base_path)) {
$controller_path = substr($controller_path, strlen($base_path) + 1);
}
// Check if controller is covered by any include path
$controller_dir = dirname($controller_path);
$controller_covered = false;
foreach ($include_paths as $include_path) {
$include_path = rtrim($include_path, '/');
// Check if include path is a file matching the controller
if (str_ends_with($include_path, '.php')) {
if ($controller_path === $include_path) {
$controller_covered = true;
break;
}
continue;
}
// Check if controller directory is within include path
if ($controller_dir === $include_path || str_starts_with($controller_dir, $include_path . '/')) {
$controller_covered = true;
break;
}
}
// Throw error if controller not covered
if (!$controller_covered) {
// Bundle doesn't include the controller
BundleErrors::controller_not_covered(
$current_controller,
$current_action,
$controller_path,
$controller_dir,
$bundle_class,
$include_paths
);
}
}
} catch (RuntimeException $e) {
// If we can't find the controller in manifest, skip validation
// (might be a Laravel controller or other edge case)
if (!str_contains($e->getMessage(), 'not found in manifest')) {
throw $e;
}
}
}
}
/**
* Get bundle metadata for manifest
*
* @return array Bundle metadata
*/
public static function get_metadata(): array
{
return [
'type' => 'bundle',
'class' => static::class,
'definition' => static::define(),
];
}
}

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* CSS concatenation script with source map support
*
* Based on concat-js.js architecture
* Uses Mozilla source-map library for standard compatibility
*
* Usage: node concat-css.js <output-file> <input-file1> <input-file2> ...
*
* This script concatenates CSS files while preserving inline source maps.
*/
const fs = require('fs');
const path = require('path');
const { SourceMapConsumer, SourceMapGenerator } = require('source-map');
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node concat-css.js <output-file> <input-file1> [input-file2] ...');
process.exit(1);
}
const outputFile = args[0];
const inputFiles = args.slice(1);
/**
* Extracts inline Base64 sourcemap from CSS content
* Returns { content: string without sourcemap, map: parsed object or null }
*/
function extractSourceMap(content, filename) {
// CSS sourcemap comment format
const regex = /\/\*#\s*sourceMappingURL=([^\s*]+)\s*\*\//m;
const match = content.match(regex);
if (!match || !match[1]) {
return { content, map: null };
}
const url = match[1];
// Handle inline Base64 data URLs
if (url.startsWith('data:')) {
const base64Match = url.match(/base64,(.*)$/);
if (base64Match) {
try {
const json = Buffer.from(base64Match[1], 'base64').toString('utf8');
const map = JSON.parse(json);
// Remove sourcemap comment from content
const cleanContent = content.replace(regex, '');
return { content: cleanContent, map };
} catch (e) {
console.warn(`Warning: Failed to parse sourcemap for ${filename}: ${e.message}`);
return { content, map: null };
}
}
}
// External sourcemap files not supported in concatenation
console.warn(`Warning: External sourcemap "${url}" in ${filename} will be ignored`);
return { content, map: null };
}
/**
* Main concatenation logic using Mozilla source-map library
*/
async function concatenateFiles() {
// Create combined sourcemap generator
const generator = new SourceMapGenerator({
file: path.basename(outputFile)
});
// Track source contents for embedding
const sourceContents = {};
// Output content parts
const outputParts = [];
// Add header comment
outputParts.push(`/* Concatenated CSS bundle: ${path.basename(outputFile)} */\n`);
outputParts.push(`/* Generated: ${new Date().toISOString()} */\n\n`);
let currentLine = 3; // Start after header comments
// Process each input file
for (const inputFile of inputFiles) {
if (!fs.existsSync(inputFile)) {
console.error(`Error: Input file not found: ${inputFile}`);
process.exit(1);
}
// Read file content
const content = fs.readFileSync(inputFile, 'utf-8');
// Generate relative path for better source map references
const relativePath = path.relative(process.cwd(), inputFile);
// Add file separator comment
const separatorComment = `/* === ${relativePath} === */\n`;
outputParts.push(separatorComment);
currentLine++;
// Extract sourcemap if present
const { content: cleanContent, map } = extractSourceMap(content, relativePath);
// Store source content for embedding
sourceContents[relativePath] = cleanContent;
if (map) {
// File has a sourcemap - merge it
const consumer = await new SourceMapConsumer(map);
// Store source contents from the existing sourcemap
if (map.sourcesContent && map.sources) {
map.sources.forEach((source, idx) => {
if (map.sourcesContent[idx]) {
sourceContents[source] = map.sourcesContent[idx];
}
});
}
// Map each line through the existing sourcemap
const lines = cleanContent.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// For each line, try to map it back to original source
const originalPos = consumer.originalPositionFor({
line: i + 1,
column: 0
});
if (originalPos.source) {
// We found an original mapping
generator.addMapping({
generated: {
line: currentLine,
column: 0
},
original: {
line: originalPos.line,
column: originalPos.column || 0
},
source: originalPos.source
});
}
outputParts.push(line + (i < lines.length - 1 ? '\n' : ''));
currentLine++;
}
consumer.destroy();
} else {
// No sourcemap - generate identity mappings
const lines = cleanContent.split('\n');
for (let i = 0; i < lines.length; i++) {
// Map each line to itself in the original file
generator.addMapping({
generated: {
line: currentLine,
column: 0
},
original: {
line: i + 1,
column: 0
},
source: relativePath
});
outputParts.push(lines[i] + (i < lines.length - 1 ? '\n' : ''));
currentLine++;
}
}
// Add extra newline between files
outputParts.push('\n');
currentLine++;
}
// Generate the final sourcemap
const mapJSON = generator.toJSON();
// Ensure sourceRoot is set properly
mapJSON.sourceRoot = '';
// Ensure all sources are relative paths
if (mapJSON.sources) {
mapJSON.sources = mapJSON.sources.map(source => {
if (!source) return source;
// If it's an absolute path, make it relative
if (path.isAbsolute(source)) {
return path.relative(process.cwd(), source);
}
return source;
});
}
// Add source contents to the sourcemap for inline viewing
if (mapJSON.sources) {
mapJSON.sourcesContent = mapJSON.sources.map(source => {
return sourceContents[source] || null;
});
}
// Convert sourcemap to Base64 and append as inline comment
const base64Map = Buffer.from(JSON.stringify(mapJSON)).toString('base64');
const finalContent = outputParts.join('') +
`\n/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map} */\n`;
// Write the output file
const outputDir = path.dirname(outputFile);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputFile, finalContent, 'utf-8');
// Report success
const fileSizeKb = (fs.statSync(outputFile).size / 1024).toFixed(2);
console.log(`✅ Concatenated ${inputFiles.length} CSS files to ${outputFile} (${fileSizeKb} KB)`);
// Report sourcemap details
console.log(` Sources in map: ${mapJSON.sources.length} files`);
const mapSizeKb = (Buffer.byteLength(base64Map, 'utf-8') / 1024).toFixed(2);
console.log(` Inline sourcemap: ${mapSizeKb} KB`);
}
// Run the concatenation
concatenateFiles().catch(err => {
console.error(`Error: ${err.message}`);
if (err.stack) {
console.error(err.stack);
}
process.exit(1);
});

View File

@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* JavaScript concatenation script with source map support
*
* MIGRATED TO MOZILLA SOURCE-MAP LIBRARY (2025-09-23)
* As per JQHTML team's RSPADE_SOURCEMAP_MIGRATION_GUIDE.md
*
* Usage: node concat-js.js <output-file> <input-file1> <input-file2> ...
*
* This script concatenates JavaScript files while preserving inline source maps
* using Mozilla's source-map library for industry-standard compatibility.
*/
const fs = require('fs');
const path = require('path');
const { SourceMapConsumer, SourceMapGenerator, SourceNode } = require('source-map');
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node concat-js.js <output-file> <input-file1> [input-file2] ...');
process.exit(1);
}
const outputFile = args[0];
const inputFiles = args.slice(1);
/**
* Extracts inline Base64 sourcemap from JavaScript content
* CRITICAL: Uses exact regex pattern from JQHTML team - DO NOT MODIFY
* Returns { content: string without sourcemap, map: parsed object or null }
*/
function extractSourceMap(content, filename) {
// EXACT regex pattern - do not modify
const regex = /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)/m;
const match = content.match(regex);
if (!match || !match[1]) {
return { content, map: null };
}
const url = match[1];
// Handle inline Base64 data URLs
if (url.startsWith('data:')) {
const base64Match = url.match(/base64,(.*)$/);
if (base64Match) {
try {
const json = Buffer.from(base64Match[1], 'base64').toString('utf8');
const map = JSON.parse(json);
// Remove sourcemap comment from content
const cleanContent = content.replace(regex, '');
return { content: cleanContent, map };
} catch (e) {
console.warn(`Warning: Failed to parse sourcemap for ${filename}: ${e.message}`);
return { content, map: null };
}
}
}
// External sourcemap files not supported in concatenation
console.warn(`Warning: External sourcemap "${url}" in ${filename} will be ignored`);
return { content, map: null };
}
/**
* Main concatenation logic using Mozilla source-map library
*/
async function concatenateFiles() {
// Create root SourceNode for concatenation
const rootNode = new SourceNode(null, null, null);
// Track source contents for embedding in sourcemap
const sourceContents = {};
// Add header comment
rootNode.add(`/* Concatenated bundle: ${path.basename(outputFile)} */\n`);
rootNode.add(`/* Generated: ${new Date().toISOString()} */\n\n`);
// Process each input file
for (const inputFile of inputFiles) {
if (!fs.existsSync(inputFile)) {
console.error(`Error: Input file not found: ${inputFile}`);
process.exit(1);
}
// Read file content
const content = fs.readFileSync(inputFile, 'utf-8');
// Generate relative path for better source map references
const relativePath = path.relative(process.cwd(), inputFile);
// Add file separator comment
rootNode.add(`/* === ${relativePath} === */\n`);
// Extract sourcemap if present
const { content: cleanContent, map } = extractSourceMap(content, relativePath);
// Store source content for embedding in sourcemap
sourceContents[relativePath] = cleanContent;
// Check if this is a compiled JQHTML file
const isJqhtml = content.includes('/* Compiled from:') && content.includes('.jqhtml */');
if (map) {
// File has a sourcemap - use it
let consumer = await new SourceMapConsumer(map);
// Apply 2-line offset for JQHTML files
if (isJqhtml) {
// JQHTML templates need a 2-line offset because the template definition
// starts on line 3 of the source (after <Define:ComponentName>)
const offsetMap = JSON.parse(JSON.stringify(map));
// Update mappings to add 2-line offset
const generator = new SourceMapGenerator({
file: offsetMap.file,
sourceRoot: offsetMap.sourceRoot
});
// Re-apply all mappings with offset
consumer.eachMapping(mapping => {
if (mapping.source && mapping.originalLine) {
generator.addMapping({
generated: {
line: mapping.generatedLine,
column: mapping.generatedColumn
},
original: {
line: mapping.originalLine + 2, // Add 2-line offset
column: mapping.originalColumn
},
source: mapping.source,
name: mapping.name
});
}
});
// Clean up old consumer
consumer.destroy();
// Use new consumer with offset mappings
const offsetMapJson = generator.toJSON();
offsetMapJson.sources = map.sources;
offsetMapJson.sourcesContent = map.sourcesContent;
consumer = await new SourceMapConsumer(offsetMapJson);
}
// Store any additional sources from the existing sourcemap
if (map.sourcesContent && map.sources) {
map.sources.forEach((source, idx) => {
if (map.sourcesContent[idx]) {
sourceContents[source] = map.sourcesContent[idx];
}
});
}
// Create a SourceNode from the file content with its sourcemap
const node = SourceNode.fromStringWithSourceMap(
cleanContent + '\n', // Add newline separator between files
consumer
);
rootNode.add(node);
// Clean up consumer to prevent memory leaks
consumer.destroy();
} else {
// No sourcemap - generate identity mappings (each line maps to itself)
const lines = cleanContent.split('\n');
const fileNode = new SourceNode();
// Apply 2-line offset for JQHTML files without existing sourcemaps
const lineOffset = isJqhtml ? 2 : 0;
for (let i = 0; i < lines.length; i++) {
// Map each line to its original position (with offset for JQHTML)
fileNode.add(new SourceNode(
i + 1 + lineOffset, // line (1-indexed) + offset for JQHTML
0, // column
relativePath, // source filename
lines[i] + (i < lines.length - 1 ? '\n' : '') // preserve newlines except last
));
}
// Add final newline separator
fileNode.add('\n');
rootNode.add(fileNode);
}
// Add extra newline between files
rootNode.add('\n');
}
// Generate the concatenated result with merged sourcemap
const { code, map } = rootNode.toStringWithSourceMap({
file: path.basename(outputFile)
});
// Convert sourcemap to JSON for final processing
const mapJSON = map.toJSON();
// Ensure sourceRoot is set properly
mapJSON.sourceRoot = '';
// Ensure all sources are relative paths
if (mapJSON.sources) {
mapJSON.sources = mapJSON.sources.map(source => {
if (!source) return source;
// If it's an absolute path, make it relative
if (path.isAbsolute(source)) {
return path.relative(process.cwd(), source);
}
return source;
});
}
// Add source contents to the sourcemap for inline viewing
if (mapJSON.sources) {
mapJSON.sourcesContent = mapJSON.sources.map(source => {
// Try to find content for this source
return sourceContents[source] || null;
});
}
// Convert sourcemap to Base64 and append as inline comment
// CRITICAL: Include charset=utf-8 as specified by JQHTML team
const base64Map = Buffer.from(JSON.stringify(mapJSON)).toString('base64');
const finalCode = code +
`\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}\n`;
// Write the output file
const outputDir = path.dirname(outputFile);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputFile, finalCode, 'utf-8');
// Report success
const fileSizeKb = (fs.statSync(outputFile).size / 1024).toFixed(2);
console.log(`✅ Concatenated ${inputFiles.length} files to ${outputFile} (${fileSizeKb} KB)`);
// Report sourcemap details
console.log(` Sources in map: ${mapJSON.sources.join(', ')}`);
const mapSizeKb = (Buffer.byteLength(base64Map, 'utf-8') / 1024).toFixed(2);
console.log(` Inline sourcemap: ${mapSizeKb} KB`);
}
// Run the concatenation
concatenateFiles().catch(err => {
console.error(`Error: ${err.message}`);
if (err.stack) {
console.error(err.stack);
}
process.exit(1);
});