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

View File

@@ -0,0 +1,75 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Core\Manifest\Manifest;
/**
* Jqhtml - Public-facing JQHTML integration class
*
* This class provides the main public API for working with JQHTML components
* in PHP/Blade templates. It outputs initialization divs that are processed
* by client-side JavaScript to mount the actual components.
*/
class Jqhtml
{
/**
* Render a JQHTML component initialization div
*
* @param string $component_name Component class name (e.g., 'User_Card')
* @param array $args Arguments to pass to the component (becomes this.args)
* @param string $slot_content Inner HTML content for the component slot
* @return string HTML div with initialization attributes
*/
public static function component(string $component_name, array $args = [], string $slot_content = ''): string
{
if ($slot_content !== '') {
return sprintf(
'<div class="Jqhtml_Component_Init" data-component-init-name="%s" data-component-args="%s">%s</div>',
htmlspecialchars($component_name),
htmlspecialchars(json_encode($args), ENT_QUOTES, 'UTF-8'),
$slot_content
);
}
return sprintf(
'<div class="Jqhtml_Component_Init" data-component-init-name="%s" data-component-args="%s"></div>',
htmlspecialchars($component_name),
htmlspecialchars(json_encode($args), ENT_QUOTES, 'UTF-8')
);
}
/**
* Get JQHTML template metadata by component ID
*
* @param string $template_id Component name (e.g., 'Counter_Widget')
* @return array|null Template file metadata from manifest, or null if not found
*/
public static function get_jqhtml_template_by_id(string $template_id): ?array
{
$manifest = Manifest::get_full_manifest();
// Look up component in jqhtml index
if (!isset($manifest['data']['jqhtml']['components'][$template_id])) {
// INTENTIONAL DEVIATION: Return null for missing template instead of throwing exception
// This allows callers to gracefully handle missing components
return null;
}
$component = $manifest['data']['jqhtml']['components'][$template_id];
// Get template file path
if (!isset($component['template_file'])) {
shouldnt_happen("JQHTML component '{$template_id}' has no template_file in manifest");
}
$template_file = $component['template_file'];
// Get full file metadata
if (!isset($manifest['data']['files'][$template_file])) {
shouldnt_happen("JQHTML template file '{$template_file}' for component '{$template_id}' not found in manifest files");
}
return $manifest['data']['files'][$template_file];
}
}

View File

@@ -0,0 +1,365 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
/**
* Custom Blade precompiler for jqhtml components
*
* Transforms uppercase component tags into jqhtml component calls.
* Example: <User_Card name="John" /> becomes Jqhtml::component('User_Card', ['name' => 'John'])
*/
class JqhtmlBladeCompiler
{
/**
* Cached list of jqhtml component names from manifest
*/
private static ?array $jqhtml_components = null;
/**
* Get list of jqhtml components from manifest
*/
private static function __get_jqhtml_components(): array
{
if (self::$jqhtml_components === null) {
// Get the cached list of jqhtml components from the manifest support module
self::$jqhtml_components = \App\RSpade\Integrations\Jqhtml\Jqhtml_ManifestSupport::get_jqhtml_components();
}
return self::$jqhtml_components;
}
/**
* Precompile Blade template to transform jqhtml component tags
*
* @param string $value The Blade template content
* @return string Transformed content
*/
public static function precompile(string $value): string
{
// Pattern to match tags that start with uppercase letter
// Matches both self-closing and paired tags
$pattern = '/<([A-Z][a-zA-Z0-9_]*)((?:\s+\$?[a-zA-Z0-9_\-:]+(?:=(?:"[^"]*"|\'[^\']*\'|[^>\s]+))?)*)\s*(?:\/>|>(.*?)<\/\1>)/s';
$value = preg_replace_callback($pattern, function ($matches) {
$component_name = $matches[1];
$attributes = $matches[2] ?? '';
$slot_content = $matches[3] ?? null;
// Parse attributes into array
$parsed_attrs = self::__parse_attributes($attributes);
// Convert to PHP array syntax
$php_array = self::__to_php_array($parsed_attrs);
// If there's slot content, we need to output the div directly to allow blade processing of the content
if ($slot_content !== null && trim($slot_content) !== '') {
// Check for slot syntax - not allowed in Blade
if (preg_match('/<#[a-zA-Z0-9_]+/', $slot_content)) {
throw new \RuntimeException(
"JQHTML slot syntax (<#slotname>) is not allowed in Blade files.\n" .
"Component '{$component_name}' contains slot tags in its innerHTML.\n" .
"Use standard innerHTML with content() function instead.\n\n" .
"Blade usage:\n" .
" <{$component_name}>\n" .
" Your content here\n" .
" </{$component_name}>\n\n" .
"Template definition:\n" .
" <Define:{$component_name}>\n" .
" <%= content() %>\n" .
" </Define:{$component_name}>"
);
}
// Check for block syntax - not allowed in Blade
if (preg_match('/<\/?Block:([a-zA-Z0-9_]+)/i', $slot_content, $block_match)) {
$block_name = $block_match[1] ?? 'Unknown';
throw new \RuntimeException(
"JQHTML block/slot syntax (<Block:{$block_name}>) is not allowed in Blade files.\n" .
"Component '{$component_name}' contains block tags in its innerHTML.\n\n" .
"Block/slot content requires jqhtml template compilation and ONLY works in .jqhtml files.\n" .
"Blade renders components server-side as standard HTML - it cannot pass block content to jqhtml components.\n\n" .
"OPTION 1: Pass data via component arguments (recommended)\n" .
" Instead of using blocks, pass all data as component arguments:\n\n" .
" WRONG (Blade):\n" .
" <{$component_name}>\n" .
" <Block:{$block_name}>\n" .
" Custom content...\n" .
" </Block:{$block_name}>\n" .
" </{$component_name}>\n\n" .
" CORRECT (Blade):\n" .
" <{$component_name} \$custom_content=\"...\" />\n\n" .
" Then handle customization in the component's .jqhtml template file.\n\n" .
"OPTION 2: Move to .jqhtml template file\n" .
" If you need block/slot functionality:\n\n" .
" 1. Create a .jqhtml wrapper component that uses blocks\n" .
" 2. Use the wrapper in Blade as a self-closing tag\n\n" .
"BLADE RULE: Jqhtml components in Blade are ALWAYS self-closing or use standard innerHTML only.\n" .
"Blocks/slots only work in .jqhtml → .jqhtml relationships."
);
}
// Recursively process slot content for nested components
$slot_content = self::precompile($slot_content);
// Separate $-prefixed attributes (component args) from regular attributes
$component_args = [];
$html_attrs = [];
$wrapper_tag = self::__get_default_wrapper_tag($component_name);
foreach ($parsed_attrs as $key => $attr) {
if (str_starts_with($key, '$')) {
// Component arg - remove $ prefix
$arg_key = substr($key, 1);
if ($attr['type'] === 'expression') {
$component_args[$arg_key] = ['type' => 'expression', 'value' => $attr['value']];
} else {
$component_args[$arg_key] = ['type' => 'string', 'value' => $attr['value']];
}
} elseif (str_starts_with($key, 'data-')) {
// Component arg - remove data- prefix
$arg_key = substr($key, 5);
if ($attr['type'] === 'expression') {
$component_args[$arg_key] = ['type' => 'expression', 'value' => $attr['value']];
} else {
$component_args[$arg_key] = ['type' => 'string', 'value' => $attr['value']];
}
} elseif ($key === 'tag') {
// Special case: tag attribute becomes _tag component arg AND sets the wrapper element
if ($attr['type'] === 'expression') {
$component_args['_tag'] = ['type' => 'expression', 'value' => $attr['value']];
} else {
$component_args['_tag'] = ['type' => 'string', 'value' => $attr['value']];
// Use the tag value as the wrapper element (only for string literals, not expressions)
$tag_value = $attr['value'];
if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $tag_value)) {
$wrapper_tag = $tag_value;
}
}
} else {
// Regular HTML attribute
$html_attrs[$key] = $attr;
}
}
// Build component args JSON
$json_args = self::__to_php_array($component_args);
if (empty($json_args) || $json_args === '[]') {
$json_args = '[]';
} else {
$json_args = "json_encode({$json_args})";
}
// Build HTML attributes string
// Handle class attribute specially to merge with Jqhtml_Component_Init
$class_value = 'Jqhtml_Component_Init';
if (isset($html_attrs['class'])) {
if ($html_attrs['class']['type'] === 'expression') {
$class_value = "Jqhtml_Component_Init ' . {$html_attrs['class']['value']} . '";
} else {
$class_value = 'Jqhtml_Component_Init ' . $html_attrs['class']['value'];
}
}
$attrs_string = ' class="' . $class_value . '"';
foreach ($html_attrs as $key => $attr) {
if ($key === 'class') {
continue; // Already handled above
}
if ($attr['type'] === 'expression') {
$attrs_string .= ' :' . $key . '="' . htmlspecialchars($attr['value']) . '"';
} elseif ($attr['value'] === true) {
$attrs_string .= ' ' . $key;
} else {
$attrs_string .= ' ' . $key . '="' . htmlspecialchars($attr['value']) . '"';
}
}
// Use {!! !!} not {{ }} because htmlspecialchars is already encoding the value
$args_output = $json_args === '[]' ? '[]' : "{!! htmlspecialchars({$json_args}, ENT_QUOTES, 'UTF-8') !!}";
return sprintf(
'<%s data-component-init-name="%s" data-component-args="%s"%s>%s</%s>',
$wrapper_tag,
$component_name,
$args_output,
$attrs_string,
$slot_content,
$wrapper_tag
);
}
// Generate the wrapper element for self-closing tags
// Separate $-prefixed attributes (component args) from regular attributes
$component_args = [];
$html_attrs = [];
$wrapper_tag = self::__get_default_wrapper_tag($component_name);
foreach ($parsed_attrs as $key => $attr) {
if (str_starts_with($key, '$')) {
// Component arg - remove $ prefix
$arg_key = substr($key, 1);
$component_args[$arg_key] = $attr;
} elseif (str_starts_with($key, 'data-')) {
// Component arg - remove data- prefix
$arg_key = substr($key, 5);
$component_args[$arg_key] = $attr;
} elseif ($key === 'tag') {
// Special case: tag attribute becomes _tag component arg AND sets the wrapper element
$component_args['_tag'] = $attr;
// Use the tag value as the wrapper element (only for string literals, not expressions)
if ($attr['type'] === 'string') {
$tag_value = $attr['value'];
if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $tag_value)) {
$wrapper_tag = $tag_value;
}
}
} else {
// Regular HTML attribute
$html_attrs[$key] = $attr;
}
}
// Build component args JSON
$json_args = self::__to_php_array($component_args);
if (empty($json_args) || $json_args === '[]') {
$json_args = '[]';
} else {
$json_args = "json_encode({$json_args})";
}
// Build HTML attributes string
// Handle class attribute specially to merge with Jqhtml_Component_Init
$class_value = 'Jqhtml_Component_Init';
if (isset($html_attrs['class'])) {
if ($html_attrs['class']['type'] === 'expression') {
$class_value = "Jqhtml_Component_Init ' . {$html_attrs['class']['value']} . '";
} else {
$class_value = 'Jqhtml_Component_Init ' . $html_attrs['class']['value'];
}
}
$attrs_string = ' class="' . $class_value . '"';
foreach ($html_attrs as $key => $attr) {
if ($key === 'class') {
continue; // Already handled above
}
if ($attr['type'] === 'expression') {
$attrs_string .= ' :' . $key . '="' . htmlspecialchars($attr['value']) . '"';
} elseif ($attr['value'] === true) {
$attrs_string .= ' ' . $key;
} else {
$attrs_string .= ' ' . $key . '="' . htmlspecialchars($attr['value']) . '"';
}
}
// Use {!! !!} not {{ }} because htmlspecialchars is already encoding the value
$args_output = $json_args === '[]' ? '[]' : "{!! htmlspecialchars({$json_args}, ENT_QUOTES, 'UTF-8') !!}";
return sprintf(
'<%s data-component-init-name="%s" data-component-args="%s"%s></%s>',
$wrapper_tag,
$component_name,
$args_output,
$attrs_string,
$wrapper_tag
);
}, $value);
return $value;
}
/**
* Parse HTML attributes into key-value pairs
*
* @param string $attributes HTML attributes string
* @return array
*/
private static function __parse_attributes(string $attributes): array
{
$parsed = [];
// Match attribute patterns (including $ prefix for literal variable names)
preg_match_all('/(\$?[a-zA-Z0-9_\-:]+)(?:=(?:"([^"]*)"|\'([^\']*)\'|([^>\s]+)))?/', $attributes, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$key = $match[1];
// Handle different attribute value formats
if (isset($match[2]) && $match[2] !== '') {
// Double quoted value
$value = $match[2];
} elseif (isset($match[3]) && $match[3] !== '') {
// Single quoted value
$value = $match[3];
} elseif (isset($match[4]) && $match[4] !== '') {
// Unquoted value
$value = $match[4];
} else {
// Boolean attribute (no value)
$value = true;
}
// Handle Blade expressions (: prefix means it's a PHP expression)
if (str_starts_with($key, ':')) {
$key = substr($key, 1);
// Value is already a PHP expression, keep as is
$parsed[$key] = ['type' => 'expression', 'value' => $value];
} else {
// Regular string value
$parsed[$key] = ['type' => 'string', 'value' => $value];
}
}
return $parsed;
}
/**
* Convert parsed attributes to PHP array syntax
*
* @param array $attrs
* @return string
*/
private static function __to_php_array(array $attrs): string
{
if (empty($attrs)) {
return '[]';
}
$parts = [];
foreach ($attrs as $key => $attr) {
if ($attr['type'] === 'expression') {
// PHP expression, use as is
$parts[] = "'{$key}' => {$attr['value']}";
} elseif ($attr['value'] === true) {
// Boolean true
$parts[] = "'{$key}' => true";
} else {
// String value, escape it
$escaped = addslashes($attr['value']);
$parts[] = "'{$key}' => '{$escaped}'";
}
}
return '[' . implode(', ', $parts) . ']';
}
/**
* Get the default wrapper tag for a component from its template metadata
*
* @param string $component_name Component name
* @return string Default wrapper tag ('div' if not specified in template)
*/
private static function __get_default_wrapper_tag(string $component_name): string
{
$template_metadata = \App\RSpade\Integrations\Jqhtml\Jqhtml::get_jqhtml_template_by_id($component_name);
if ($template_metadata !== null && isset($template_metadata['tag_name'])) {
return $template_metadata['tag_name'];
}
return 'div';
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
use App\RSpade\Integrations\Jqhtml\Jqhtml_ErrorPageRenderer;
/**
* Custom exception renderer that uses our modified error page renderer
*/
#[Instantiatable]
class JqhtmlExceptionRenderer implements ExceptionRenderer
{
protected Jqhtml_ErrorPageRenderer $errorPageHandler;
public function __construct(Jqhtml_ErrorPageRenderer $errorPageHandler)
{
$this->errorPageHandler = $errorPageHandler;
}
public function render($throwable)
{
ob_start();
$this->errorPageHandler->render($throwable);
return ob_get_clean();
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use App\RSpade\Integrations\Jqhtml\JqhtmlBladeCompiler;
/**
* Jqhtml Service Provider
*
* Registers jqhtml components with Laravel's Blade component system
*/
#[Instantiatable]
class JqhtmlServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services
*/
public function boot(): void
{
// Register the Blade precompiler for uppercase jqhtml component tags
Blade::precompiler(function ($value) {
return JqhtmlBladeCompiler::precompile($value);
});
// Register the generic x-jqhtml component for kebab-case usage
Blade::component('jqhtml', \App\RSpade\Integrations\Jqhtml\Jqhtml_View_Component::class);
// Also register a Blade directive for simpler syntax
Blade::directive('jqhtml', function ($expression) {
// Parse the expression to extract component and args
// Example: @jqhtml('User_Card', ['name' => 'John'])
return "<?php echo \\App\\RSpade\\Integrations\\Jqhtml\\Jqhtml::component({$expression}); ?>";
});
}
}

View File

@@ -0,0 +1,320 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Integrations\Jqhtml\Jqhtml_Exception_ViewException;
/**
* JqhtmlWebpackCompiler - Compiles JQHTML templates to JavaScript during bundle build
*
* This service handles the compilation of .jqhtml template files into JavaScript
* during the bundle build process. It uses the @jqhtml/parser NPM package to
* perform the compilation and caches the results based on file modification times.
*
* Features:
* - Uses @jqhtml/parser for template compilation
* - Caches compiled templates based on file mtime
* - Throws fatal errors on compilation failures (fail loud)
* - Integrates with the bundle compilation pipeline
*/
#[Instantiatable]
class JqhtmlWebpackCompiler
{
/**
* Path to the jqhtml-compile binary
*/
protected string $compiler_path;
/**
* Cache directory for compiled templates
*/
protected string $cache_dir;
/**
* Constructor
*/
public function __construct()
{
// Use official jqhtml CLI compiler from npm package
$this->compiler_path = base_path('node_modules/@jqhtml/parser/bin/jqhtml-compile');
$this->cache_dir = storage_path('rsx-tmp/jqhtml-cache');
// Ensure cache directory exists
if (!is_dir($this->cache_dir)) {
mkdir($this->cache_dir, 0755, true);
}
// Validate compiler exists - MUST exist
if (!file_exists($this->compiler_path)) {
throw new \RuntimeException(
"Official JQHTML CLI compiler not found at: {$this->compiler_path}. " .
"Run 'npm install @jqhtml/parser@^2.2.59' to install the official CLI compiler."
);
}
}
/**
* Compile a single JQHTML template file
*
* @param string $file_path Path to .jqhtml file
* @return string Compiled JavaScript code
* @throws \RuntimeException On compilation failure
*/
public function compile_file(string $file_path): string
{
if (!file_exists($file_path)) {
throw new \RuntimeException("JQHTML template not found: {$file_path}");
}
// Get file modification time for cache key
$mtime = filemtime($file_path);
$cache_key = md5($file_path) . '_' . $mtime;
$cache_file = $this->cache_dir . '/' . $cache_key . '.js';
// Check if cached version exists
if (file_exists($cache_file)) {
console_debug("JQHTML", "Using cached JQHTML template: {$file_path}");
return file_get_contents($cache_file);
}
console_debug("JQHTML", "Compiling JQHTML template: {$file_path}");
// Execute official CLI compiler with IIFE format for self-registering templates
// CRITICAL: Must include --sourcemap for proper error mapping in bundles
// JQHTML v2.2.65+ uses Mozilla source-map library for reliable concatenation
// IMPORTANT: Using proc_open() instead of \exec_safe() to handle large template outputs
// \exec_safe() can truncate output for complex templates due to line-by-line buffering
$command = sprintf(
'%s compile %s --format iife --sourcemap',
escapeshellarg($this->compiler_path),
escapeshellarg($file_path)
);
$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'] // stderr
];
$process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) {
throw new \RuntimeException("Failed to execute jqhtml compiler");
}
// Close stdin
fclose($pipes[0]);
// Set blocking mode to ensure complete reads
stream_set_blocking($pipes[1], true);
stream_set_blocking($pipes[2], true);
// Read stdout and stderr completely in chunks
// CRITICAL: Use feof() as loop condition to prevent race condition truncation
// Checking feof() AFTER empty reads can cause 8192-byte truncation bug
$output_str = '';
$error_str = '';
// Read stdout until EOF
while (!feof($pipes[1])) {
$chunk = fread($pipes[1], 8192);
if ($chunk !== false) {
$output_str .= $chunk;
}
}
// Read stderr until EOF
while (!feof($pipes[2])) {
$chunk = fread($pipes[2], 8192);
if ($chunk !== false) {
$error_str .= $chunk;
}
}
fclose($pipes[1]);
fclose($pipes[2]);
// Get return code
$return_code = proc_close($process);
// Combine stdout and stderr for error messages
if ($return_code !== 0 && !empty($error_str)) {
$output_str = $error_str . "\n" . $output_str;
}
// Check for compilation errors
if ($return_code !== 0) {
// Official CLI outputs errors to stderr (captured in stdout with 2>&1)
// Try multiple error formats
// Format 1: "at filename:line:column" (newer format)
if (preg_match('/at [^:]+:(\d+):(\d+)/i', $output_str, $matches)) {
$line = (int)$matches[1];
$column = (int)$matches[2];
throw new Jqhtml_Exception_ViewException(
"JQHTML compilation failed:\n{$output_str}",
$file_path,
$line,
$column
);
}
// Format 2: "Error at line X, column Y:" (older format)
if (preg_match('/Error at line (\d+), column (\d+):/i', $output_str, $matches)) {
$line = (int)$matches[1];
$column = (int)$matches[2];
throw new Jqhtml_Exception_ViewException(
"JQHTML compilation failed:\n{$output_str}",
$file_path,
$line,
$column
);
}
// Format 3: No line number found - generic error
throw new \RuntimeException(
"JQHTML compilation failed for {$file_path}:\n{$output_str}"
);
}
// Success - the output is the compiled JavaScript
$compiled_js = $output_str;
// Don't add any comments - they break sourcemap line offsets
// Just use the compiler output as-is
$wrapped_js = $compiled_js;
// Ensure proper newline at end
if (!str_ends_with($wrapped_js, "\n")) {
$wrapped_js .= "\n";
}
// Cache the compiled result
file_put_contents($cache_file, $wrapped_js);
// Clean up old cache files for this template
$this->cleanup_old_cache($file_path, $cache_key);
return $wrapped_js;
}
/**
* Compile multiple JQHTML template files
*
* @param array $files Array of file paths
* @return array Compiled JavaScript code keyed by file path
*/
public function compile_files(array $files): array
{
$compiled = [];
foreach ($files as $file) {
try {
$compiled[$file] = $this->compile_file($file);
} catch (\Exception $e) {
// FAIL LOUD - don't continue on error
throw new \RuntimeException(
"Failed to compile JQHTML templates: " . $e->getMessage()
);
}
}
return $compiled;
}
/**
* Extract component name from file path
*
* @param string $file_path Path to .jqhtml file
* @return string Component name
*/
protected function extract_component_name(string $file_path): string
{
// Remove base path and extension
$relative = str_replace(base_path() . '/', '', $file_path);
$relative = preg_replace('/\.jqhtml$/i', '', $relative);
// Convert path to component name (e.g., rsx/app/components/MyComponent)
// to MyComponent or components/MyComponent
$parts = explode('/', $relative);
// Use the filename as the component name
return basename($relative);
}
/**
* Clean up old cache files for a template
*
* @param string $file_path Original template path
* @param string $current_cache_key Current cache key to keep
*/
protected function cleanup_old_cache(string $file_path, string $current_cache_key): void
{
$file_hash = md5($file_path);
$pattern = $this->cache_dir . '/' . $file_hash . '_*.js';
foreach (glob($pattern) as $cache_file) {
$cache_key = basename($cache_file, '.js');
if ($cache_key !== $current_cache_key) {
unlink($cache_file);
}
}
}
/**
* Clear all cached templates
*/
public function clear_cache(): void
{
$pattern = $this->cache_dir . '/*.js';
foreach (glob($pattern) as $cache_file) {
unlink($cache_file);
}
console_debug("JQHTML", "Cleared JQHTML template cache");
}
/**
* Get cache statistics
*
* @return array Cache statistics
*/
public function get_cache_stats(): array
{
$pattern = $this->cache_dir . '/*.js';
$files = glob($pattern);
$total_size = 0;
foreach ($files as $file) {
$total_size += filesize($file);
}
return [
'cache_dir' => $this->cache_dir,
'cached_files' => count($files),
'total_size' => $total_size,
'total_size_human' => $this->format_bytes($total_size)
];
}
/**
* Format bytes to human readable
*
* @param int $bytes
* @return string
*/
protected function format_bytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* JQHTML Runtime Bundle
*
* Provides the JQHTML runtime JavaScript files needed for template rendering.
* The Jqhtml_BundleProcessor handles compilation of .jqhtml files separately.
*/
class Jqhtml_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [
__DIR__,
],
'npm' => [
'jqhtml' => "import jqhtml from '@jqhtml/core'",
'_Base_Jqhtml_Component' => "import { Jqhtml_Component } from '@jqhtml/core'",
],
];
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Core\Bundle\BundleIntegration_Abstract;
use App\RSpade\Integrations\Jqhtml\Jqhtml_BundleProcessor;
use App\RSpade\Integrations\Jqhtml\Jqhtml_ManifestModule;
/**
* JqhtmlIntegration - JQHTML template system integration
*
* This integration adds support for JQHTML templates to the RSX framework.
* It provides:
* - Discovery of .jqhtml template files
* - Compilation of templates to JavaScript
* - Runtime library inclusion
* - Automatic binding between templates and ES6 classes
*
* JQHTML is a jQuery-based component templating system that compiles
* HTML-like syntax to JavaScript functions for efficient rendering.
*/
class Jqhtml_BundleIntegration extends BundleIntegration_Abstract
{
/**
* Get the integration's unique identifier
*
* @return string
*/
public static function get_name(): string
{
return 'jqhtml';
}
/**
* Get the ManifestModule class for file discovery
*
* @return string|null
*/
public static function get_manifest_module(): ?string
{
return Jqhtml_ManifestModule::class;
}
/**
* Get the BundleModule_Abstract class for asset provision
*
* @return string|null
*/
public static function get_bundle_module(): ?string
{
return null; // BundleModule system is obsolete
}
/**
* Get the Processor class for file transformation
*
* @return string|null
*/
public static function get_processor(): ?string
{
return Jqhtml_BundleProcessor::class;
}
/**
* Get file extensions handled by this integration
*
* @return array
*/
public static function get_file_extensions(): array
{
return ['jqhtml', 'jqtpl'];
}
/**
* Get configuration options for this integration
*
* @return array
*/
public static function get_config(): array
{
return [
'compiler' => [
'cache' => env('JQHTML_CACHE', true),
'cache_ttl' => env('JQHTML_CACHE_TTL', 3600),
'source_maps' => env('JQHTML_SOURCE_MAPS', !app()->environment('production')),
],
'runtime' => [
'bundle' => 'node_modules/@jqhtml/core/dist/index.js',
],
'search_patterns' => [
'rsx/**/*.jqhtml',
'rsx/**/*.jqtpl',
],
];
}
/**
* Get priority for this integration
*
* @return int
*/
public static function get_priority(): int
{
return 300; // After jQuery (100) and Lodash (90), before most other integrations
}
/**
* Bootstrap the integration
*/
public static function bootstrap(): void
{
// JQHTML runtime MUST exist - fail loud if not
$runtime_path = base_path(static::get_config()['runtime']['bundle']);
if (!file_exists($runtime_path)) {
throw new \RuntimeException("JQHTML runtime not found at: {$runtime_path}. Run 'npm install' to install @jqhtml packages.");
}
// Register automatic template-to-class binding
// This would be implemented to automatically bind templates to matching ES6 classes
}
/**
* Get dependencies for this integration
*
* @return array
*/
public static function get_dependencies(): array
{
return ['jquery']; // JQHTML requires jQuery to be loaded first
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use Exception;
use RuntimeException;
use App\RSpade\Core\Bundle\BundleProcessor_Abstract;
use App\RSpade\Integrations\Jqhtml\JqhtmlWebpackCompiler;
use App\RSpade\Integrations\Jqhtml\Jqhtml_Exception_ViewException;
/**
* JqhtmlProcessor - Processes JQHTML template files
*
* Compiles JQHTML templates into JavaScript code that can be
* included in bundles. Uses webpack-based compilation with caching.
*/
class Jqhtml_BundleProcessor extends BundleProcessor_Abstract
{
/**
* Compiler instance
*/
protected static ?JqhtmlWebpackCompiler $compiler = null;
/**
* Get processor name
*/
public static function get_name(): string
{
return 'jqhtml';
}
/**
* Get file extensions this processor handles
*/
public static function get_extensions(): array
{
return ['jqhtml'];
}
/**
* Process multiple files in batch
* Compiles JQHTML templates and appends the JavaScript output to the bundle
*/
public static function process_batch(array &$bundle_files): void
{
// Check for jqhtml files
$jqhtml_files = array_filter($bundle_files, function ($file) {
return pathinfo($file, PATHINFO_EXTENSION) === 'jqhtml';
});
console_debug('JQHTML', 'process_batch called with ' . count($bundle_files) . ' files, ' . count($jqhtml_files) . ' jqhtml files');
if (empty($jqhtml_files)) {
console_debug('JQHTML', 'No jqhtml files to process');
return;
}
// Initialize compiler if needed
if (!static::$compiler) {
static::$compiler = new JqhtmlWebpackCompiler();
}
// Process each JQHTML file
foreach ($bundle_files as $path) {
$ext = pathinfo($path, PATHINFO_EXTENSION);
// Only process JQHTML files
if ($ext !== 'jqhtml') {
continue; // Skip non-JQHTML files
}
console_debug('JQHTML', "Processing file: {$path}");
// Generate temp file path for compiled output
$cache_key = md5($path . ':' . filemtime($path) . ':' . filesize($path));
$temp_file = storage_path('rsx-tmp/jqhtml_' . substr($cache_key, 0, 16) . '.js');
// Check if we need to compile
$needs_compile = !file_exists($temp_file) ||
(filemtime($path) > filemtime($temp_file));
if ($needs_compile) {
console_debug('JQHTML', "Compiling: {$path}");
try {
// Compile the template using webpack compiler
$js_code = static::$compiler->compile_file($path);
// Strip the compiler's comment line if present (single-line // comment)
if (preg_match('/^\/\/[^\n]*\n(.*)$/s', $js_code, $matches)) {
$js_code = $matches[1];
}
// Add our comment inline without any newlines to preserve sourcemap line offsets
$relative_path = str_replace(base_path() . '/', '', $path);
$wrapped_code = "/* Compiled from: {$relative_path} */ {$js_code}";
// Ensure proper newline at end
if (!str_ends_with($wrapped_code, "\n")) {
$wrapped_code .= "\n";
}
// Write to temp file
file_put_contents($temp_file, $wrapped_code);
console_debug('JQHTML', "Compiled {$path} -> {$temp_file} (" . strlen($wrapped_code) . ' bytes)');
} catch (Jqhtml_Exception_ViewException $e) {
// Let JQHTML ViewExceptions pass through for proper Ignition display
throw $e;
} catch (\Illuminate\View\ViewException $e) {
// Let ViewExceptions pass through for proper display
throw $e;
} catch (Exception $e) {
// FAIL LOUD - re-throw other exceptions
throw new RuntimeException(
"Failed to process JQHTML template {$path}: " . $e->getMessage()
);
}
} else {
console_debug('JQHTML', "Using cached: {$temp_file}");
}
// ALWAYS append the compiled JS file to the bundle (whether freshly compiled or cached)
$bundle_files[] = $temp_file;
}
console_debug('JQHTML', 'Final bundle_files count: ' . count($bundle_files));
}
/**
* Post-processing hook - no longer generates manifest
* Templates self-register via their compiled code
*/
public static function after_processing(array $processed_files, array $options = []): array
{
// Templates now self-register when their compiled JS executes
// No need for separate manifest generation
return [];
}
/**
* Pre-processing hook - reset compiled templates cache
*/
public static function before_processing(array $all_files, array $options = []): void
{
}
/**
* Get processor priority (processes before JS)
*/
public static function get_priority(): int
{
return 400; // Process before JavaScript files
}
/**
* Validate processor configuration
*/
public static function validate(): void
{
// JqhtmlWebpackCompiler must exist - it's a required part of the jqhtml integration
// Check if @jqhtml/parser is installed
$package_path = base_path('node_modules/@jqhtml/parser/package.json');
if (!file_exists($package_path)) {
throw new RuntimeException(
"@jqhtml/parser NPM package not found. Run 'npm install' to install @jqhtml packages."
);
}
}
/**
* Check if processor should run in current environment
*
* @return bool True if processor should run
*/
public static function is_enabled(): bool
{
// Always enabled - bundles control inclusion via module dependencies
return true;
}
}

View File

@@ -0,0 +1,13 @@
/**
* Jqhtml_Component - Base class for JQHTML components in RSX framework
*
* This class wraps the jqhtml.Component from the npm package and provides
* the standard interface for RSX components following the Upper_Case naming convention.
*
* _Base_Jqhtml_Component is imported from npm via Jqhtml_Bundle.
*
* @Instantiatable
*/
class Jqhtml_Component extends _Base_Jqhtml_Component {}
// RSX manifest automatically makes classes global - no manual assignment needed

View File

@@ -0,0 +1,94 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use Spatie\LaravelIgnition\Renderers\ErrorPageRenderer;
use Throwable;
/**
* Custom error page renderer that extends Ignition's error renderer
* to properly display multi-line error messages without truncation
*/
#[Instantiatable]
class Jqhtml_ErrorPageRenderer extends ErrorPageRenderer
{
/**
* Render the error page with custom JavaScript to fix multi-line display
*/
public function render(Throwable $throwable): void
{
// Create custom JavaScript to convert newlines to <br> tags in error messages
$customScript = <<<'JS'
<script>
// Fix multi-line error messages in Ignition by converting \n to <br>
document.addEventListener('DOMContentLoaded', function() {
// Wait for React to render
setTimeout(function() {
// Find all elements that might contain error messages
const selectors = [
'.message',
'.exception-message',
'[class*="message"]',
'.line-clamp-2',
'h1',
'title'
];
selectors.forEach(function(selector) {
document.querySelectorAll(selector).forEach(function(element) {
const text = element.textContent || element.innerText || '';
// Check if element contains newlines
if (text.includes('\n')) {
// Convert newlines to <br> tags
const html = text
.split('\n')
.map(line => {
// Escape HTML but preserve spaces at start of lines
const escaped = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// Convert leading spaces to non-breaking spaces
return escaped.replace(/^( +)/, function(match) {
return '&nbsp;'.repeat(match.length);
});
})
.join('<br>');
element.innerHTML = html;
// Remove truncation styles
element.classList.remove('line-clamp-2', 'truncate');
element.style.overflow = 'visible';
element.style.display = 'block';
element.style.webkitLineClamp = 'none';
element.style.webkitBoxOrient = 'horizontal';
}
});
});
// Do it again after a longer delay for any lazy-loaded content
setTimeout(arguments.callee, 500);
}, 100);
});
</script>
JS;
// Call parent render method with our custom script added
app(\Spatie\Ignition\Ignition::class)
->resolveDocumentationLink(
fn (Throwable $throwable) => (new \Spatie\LaravelIgnition\Support\LaravelDocumentationLinkFinder())->findLinkForThrowable($throwable)
)
->setFlare(app(\Spatie\FlareClient\Flare::class))
->setConfig(app(\Spatie\Ignition\Config\IgnitionConfig::class))
->setSolutionProviderRepository(app(\Spatie\ErrorSolutions\Contracts\SolutionProviderRepository::class))
->setContextProviderDetector(new \Spatie\LaravelIgnition\ContextProviders\LaravelContextProviderDetector())
->setSolutionTransformerClass(\Spatie\LaravelIgnition\Solutions\SolutionTransformers\LaravelSolutionTransformer::class)
->applicationPath(base_path())
->addCustomHtmlToHead($customScript)
->renderException($throwable);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use Illuminate\View\ViewException;
/**
* Custom exception for JQHTML template compilation errors
* Extends ViewException to integrate with Laravel's error handling
*/
#[Instantiatable]
class Jqhtml_Exception_ViewException extends ViewException
{
protected ?int $column = null;
protected ?string $context = null;
protected ?string $suggestion = null;
protected string $rawMessage = '';
/**
* Create a new JQHTML exception
*/
public function __construct(string $message, string $file, int $line = 0, int $column = 0, ?\Throwable $previous = null)
{
// Store the raw message
$this->rawMessage = $message;
// Call parent constructor with the raw message
// ViewException will handle the display
parent::__construct($message, 0, 1, $file, $line, $previous);
$this->column = $column;
}
/**
* Get the column number
*/
public function getColumn(): ?int
{
return $this->column;
}
/**
* Set the column number
*/
public function setColumn(int $column): void
{
$this->column = $column;
}
/**
* Get the code context
*/
public function getContext(): ?string
{
return $this->context;
}
/**
* Set the code context
*/
public function setContext(string $context): void
{
$this->context = $context;
}
/**
* Get the suggestion
*/
public function getSuggestion(): ?string
{
return $this->suggestion;
}
/**
* Set the suggestion
*/
public function setSuggestion(string $suggestion): void
{
$this->suggestion = $suggestion;
}
/**
* Get the raw unformatted message
*/
public function getRawMessage(): string
{
return $this->rawMessage;
}
/**
* Get the view source - override to handle .jqhtml files
* Ignition uses this to display the source code preview
*/
public function getSourceCode(): string
{
if (file_exists($this->getFile())) {
return file_get_contents($this->getFile());
}
return '';
}
}

View File

@@ -0,0 +1,156 @@
/**
* JQHTML Integration - Automatic component registration and binding
*
* This module automatically:
* 1. Registers component classes that extend Jqhtml_Component
* 2. Binds templates to component classes when names match
* 3. Enables $(selector).component("Component_Name") syntax
*/
class Jqhtml_Integration {
/**
* Compiled Jqhtml templates self-register. The developer (the framework in this case) is still
* responsible for registering es6 component classes with jqhtml. This does so at an early stage
* of framework init.
*/
static _on_framework_modules_define() {
let jqhtml_components = Manifest.get_extending('Jqhtml_Component');
console_debug('JQHTML_INIT', 'Registering ' + jqhtml_components.length + ' Jqhtml Components');
for (let component of jqhtml_components) {
jqhtml.register_component(component.class_name, component.class_object);
}
}
/**
* Framework modules init phase - Bind components and initialize DOM
* This runs after templates are registered to bind component classes
* @param {jQuery} [$scope] Optional scope to search within (defaults to body)
* @returns {Array<Promise>|undefined} Array of promises for recursive calls, undefined for top-level
*/
static _on_framework_modules_init($scope) {
const is_top_level = !$scope;
const promises = [];
const components_needing_init = ($scope || $('body')).find('.Jqhtml_Component_Init');
if (components_needing_init.length > 0) {
console_debug('JQHTML_INIT', `Initializing ${components_needing_init.length} DOM components`);
}
components_needing_init.each(function () {
const $element = $(this);
// Check if any parent has Jqhtml_Component_Init class - skip nested components
let parent = $element[0].parentElement;
while (parent) {
if (parent.classList.contains('Jqhtml_Component_Init')) {
return; // Skip this element, it's nested
}
parent = parent.parentElement;
}
const component_name = $element.attr('data-component-init-name');
// jQuery's .data() doesn't auto-parse JSON - we need to parse it manually
let component_args = {};
const args_string = $element.attr('data-component-args');
// Unset component- php side initialization args, it is no longer needed as a compionent attribute
// Unsetting also prevents undesired access to this code in other parts of the program, prevening an
// unwanted future dependency on this paradigm
$element.removeAttr('data-component-init-name');
$element.removeAttr('data-component-args');
$element.removeData('component-init-name');
$element.removeData('component-args');
if (args_string) {
try {
component_args = JSON.parse(args_string);
} catch (e) {
console.error(`[JQHTML Integration] Failed to parse component args for ${component_name}:`, e);
component_args = {};
}
}
if (component_name) {
// Transform $ prefixed keys to data- attributes
let component_args_filtered = {};
for (const [key, value] of Object.entries(component_args)) {
// if (key.startsWith('$')) {
// component_args_filtered[key.substring(1)] = value;
// } else
if (key.startsWith('data-')) {
component_args_filtered[key.substring(5)] = value;
} else {
component_args_filtered[key] = value;
}
}
try {
// Store inner HTML as string for nested component processing
component_args_filtered._inner_html = $element.html();
$element.empty();
// Remove the init class before instantiation to prevent re-initialization
$element.removeClass('Jqhtml_Component_Init');
// Create promise for this component's initialization
const component_promise = new Promise((resolve) => {
// Use jQuery component plugin to create the component
// Plugin handles element internally, just pass args
// Get the updated $element from
let component = $element.component(component_name, component_args_filtered);
component.on('ready', function () {
// Recursively collect promises from nested components
// Getting the updated component here - if the tag name was not div, the element would have been recreated, so we need to get the element set on the component, not from our earlier selector
const nested_promises = Jqhtml_Integration._on_framework_modules_init(component.$);
promises.push(...nested_promises);
// Resolve this component's promise
resolve();
}).$;
});
promises.push(component_promise);
} catch (error) {
console.error(`[JQHTML Integration] Failed to initialize component ${component_name}:`, error);
console.error('Error details:', error.stack || error);
}
}
});
// Top-level call: spawn async handler to wait for all promises, then trigger event
if (is_top_level) {
(async () => {
await Promise.all(promises);
await Rsx._rsx_call_all_classes('on_jqhtml_ready');
Rsx.trigger('jqhtml_ready');
})();
return;
}
// Recursive call: return promises for parent to collect
return promises;
}
/**
* Get all registered component names
* @returns {Array<string>} Array of component names
*/
static get_component_names() {
return jqhtml.get_component_names();
}
/**
* Check if a component is registered
* @param {string} name Component name
* @returns {boolean} True if component is registered
*/
static has_component(name) {
return jqhtml.has_component(name);
}
}
// RSX manifest automatically makes classes global - no manual assignment needed

View File

@@ -0,0 +1,3 @@
.Jqhtml_Component_Init {
display:none;
}

View File

@@ -0,0 +1,265 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Core\Manifest\ManifestModule_Abstract;
/**
* JqhtmlManifestModule - Manifest module for JQHTML template files
*
* This module handles discovery and metadata extraction for .jqhtml template files.
* It extracts:
* - Template name from <Define:ComponentName> syntax
* - Component references (PascalCase tags)
* - Dependencies (@import directives)
* - Template metadata for component binding
*/
class Jqhtml_ManifestModule extends ManifestModule_Abstract
{
/**
* Get file extensions this module handles
*
* @return array
*/
public function handles(): array
{
return ['jqhtml', 'jqtpl'];
}
/**
* Get processing priority (lower = earlier)
*
* @return int
*/
public function priority(): int
{
return 500; // Process after core types but before CSS
}
/**
* Process a file and extract metadata
*
* @param string $file_path Full path to the file
* @param array $metadata Existing metadata
* @return array Updated metadata
*/
public function process(string $file_path, array $metadata): array
{
// Read template content
$content = file_get_contents($file_path);
// Check if file has meaningful content after comment removal
$cleaned_content = $this->remove_comments($content);
$trimmed_content = trim($cleaned_content);
// If file is effectively empty after comments removed and trimmed, don't require a template name
if (empty($trimmed_content)) {
$metadata['type'] = 'jqhtml_template';
$metadata['components'] = [];
$metadata['dependencies'] = [];
$metadata['slots'] = [];
return $metadata;
}
// Extract template name
$template_name = $this->extract_template_name($content);
if (!$template_name) {
// Non-empty file must have a template name - fail loud
throw new \RuntimeException("JQHTML template in {$file_path} has content but no <Define:ComponentName> tag. Please add a template definition to the file.");
}
// Extract component references
$components = $this->extract_components($content);
// Extract dependencies
$dependencies = $this->extract_dependencies($content);
// Extract slots
$slots = $this->extract_slots($content);
// Extract tag name (defaults to 'div' if not specified)
$tag_name = $this->extract_tag_name($content);
// Add JQHTML-specific metadata
$metadata['template_name'] = $template_name;
$metadata['id'] = $template_name; // Set the ID to the component name
$metadata['type'] = 'jqhtml_template';
$metadata['tag_name'] = $tag_name;
$metadata['components'] = $components;
$metadata['dependencies'] = $dependencies;
$metadata['slots'] = $slots;
// Check if this template has a matching ES6 class
$class_name = $this->to_class_name($template_name);
$metadata['expected_class'] = $class_name;
return $metadata;
}
/**
* Remove comments from JQHTML content
*
* @param string $content Template content
* @return string Content with comments removed
*/
protected function remove_comments(string $content): string
{
// Remove <%-- --%> style comments
$content = preg_replace('/<%--.*?--%>/s', '', $content);
// Remove <!-- --> style comments
$content = preg_replace('/<!--.*?-->/s', '', $content);
return $content;
}
/**
* Extract template name from content
*
* @param string $content Template content
* @return string|null Template name
*/
protected function extract_template_name(string $content): ?string
{
// Remove comments before searching for Define:
$content = $this->remove_comments($content);
// Find the first instance of <Define: and capture alphanumeric+underscore characters
// The component name continues until we hit a non-alphanumeric/non-underscore character
if (preg_match('/<Define:(\w+)/', $content, $matches)) {
return $matches[1];
}
return null;
}
/**
* Extract tag name from Define statement
*
* @param string $content Template content
* @return string Tag name (defaults to 'div' if not specified)
*/
protected function extract_tag_name(string $content): string
{
// Remove comments before searching
$content = $this->remove_comments($content);
// Look for tag attribute in Define statement
// Matches: <Define:ComponentName tag="span"> or <Define:ComponentName tag='span'>
if (preg_match('/<Define:\w+\s+[^>]*tag=["\']([^"\']+)["\']/', $content, $matches)) {
return $matches[1];
}
return 'div'; // Default tag name
}
/**
* Extract component references from template
*
* @param string $content Template content
* @return array Component names
*/
protected function extract_components(string $content): array
{
$components = [];
// Match PascalCase tags (likely components)
preg_match_all('/<([A-Z][a-zA-Z0-9_]*)(?:\s+[^>]*)?\/?>|<\/([A-Z][a-zA-Z0-9_]*)>/', $content, $matches);
foreach ($matches[1] as $tag) {
if ($tag && !in_array($tag, $components)) {
$components[] = $tag;
}
}
foreach ($matches[2] as $tag) {
if ($tag && !in_array($tag, $components)) {
$components[] = $tag;
}
}
// Remove Define tags as they're not component references
$components = array_filter($components, function($c) {
return !str_starts_with($c, 'Define');
});
return array_values($components);
}
/**
* Extract dependencies from template
*
* @param string $content Template content
* @return array Dependency paths
*/
protected function extract_dependencies(string $content): array
{
$dependencies = [];
// Match @import directives
preg_match_all('/@import\s+[\'"]([^\'"]+)[\'"]/', $content, $matches);
foreach ($matches[1] as $dep) {
if ($dep && !in_array($dep, $dependencies)) {
$dependencies[] = $dep;
}
}
return $dependencies;
}
/**
* Extract slot definitions from template
*
* @param string $content Template content
* @return array Slot names
*/
protected function extract_slots(string $content): array
{
$slots = [];
// Match <#slotname> syntax
preg_match_all('/<#(\w+)>/', $content, $matches);
foreach ($matches[1] as $slot) {
if ($slot && !in_array($slot, $slots)) {
$slots[] = $slot;
}
}
// Also match <slot name="..."> syntax
preg_match_all('/<slot\s+name=[\'"](\w+)[\'"]/', $content, $matches);
foreach ($matches[1] as $slot) {
if ($slot && !in_array($slot, $slots)) {
$slots[] = $slot;
}
}
return $slots;
}
/**
* Convert template name to expected class name
*
* @param string $template_name Template name
* @return string Expected class name
*/
protected function to_class_name(string $template_name): string
{
// Handle snake_case to PascalCase
if (strpos($template_name, '_') !== false) {
$parts = explode('_', $template_name);
return implode('', array_map('ucfirst', $parts));
}
// Handle kebab-case to PascalCase
if (strpos($template_name, '-') !== false) {
$parts = explode('-', $template_name);
return implode('', array_map('ucfirst', $parts));
}
// Already PascalCase or needs first letter capitalized
return ucfirst($template_name);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use Exception;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for extracting jqhtml component metadata
* This runs after the primary manifest is built to add jqhtml component metadata
*/
class Jqhtml_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Process the manifest and add jqhtml component metadata
*
* Doesn't actually read any files, just collates the data in the manifest
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize jqhtml key if it doesn't exist
if (!isset($manifest_data['data']['jqhtml'])) {
$manifest_data['data']['jqhtml'] = [];
}
$components = [];
// Build map of component_name => js_file for classes extending Jqhtml_Component
$js_classes = [];
try {
$extending_components = Manifest::js_get_extending('Jqhtml_Component');
foreach ($extending_components as $component_info) {
if (isset($component_info['class']) && isset($component_info['file'])) {
$js_classes[$component_info['class']] = $component_info['file'];
}
}
} catch (Exception $e) {
// Manifest not ready yet, skip
}
// Get all jqhtml template files from manifest and build component map
if (isset($manifest_data['data']['files'])) {
foreach ($manifest_data['data']['files'] as $file_path => $file_data) {
if (isset($file_data['type']) && $file_data['type'] === 'jqhtml_template') {
if (isset($file_data['template_name'])) {
$component_name = $file_data['template_name'];
$components[$component_name] = [
'name' => $component_name,
'template_file' => $file_path,
];
// Add JS file if component has a class
if (isset($js_classes[$component_name])) {
$components[$component_name]['js_file'] = $js_classes[$component_name];
}
}
}
}
}
// Store component map (associative array: component_name => metadata)
$manifest_data['data']['jqhtml']['components'] = $components;
$manifest_data['data']['jqhtml']['component_count'] = count($components);
}
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Jqhtml Component Metadata';
}
/**
* Static method to get jqhtml components from cached manifest
* This is called by JqhtmlBladeCompiler to get the list of components
*
* @return array List of jqhtml component names
*/
public static function get_jqhtml_components(): array
{
try {
// Get the full manifest data
$manifest = Manifest::get_full_manifest();
if (isset($manifest['data']['jqhtml']['components'])) {
return $manifest['data']['jqhtml']['components'];
}
} catch (Exception $e) {
// Manifest not ready, return empty
}
return [];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Core\Integration_Service_Provider_Abstract;
use App\RSpade\Integrations\Jqhtml\Jqhtml_BundleIntegration;
/**
* Jqhtml_Service_Provider - Service provider for JQHTML integration
*
* This provider registers the JQHTML integration with the RSX framework.
* It handles:
* - Registering file extensions with ExtensionRegistry
* - Registering the processor with BundleCompiler
* - Registering the manifest module with ManifestKernel
* - Bootstrapping the JQHTML runtime
*
* To use this integration, register this provider in config/app.php:
* App\RSpade\Integrations\Jqhtml\Jqhtml_Service_Provider::class
*
* Or register it conditionally in AppServiceProvider if needed.
*/
class Jqhtml_Service_Provider extends Integration_Service_Provider_Abstract
{
/**
* Get the integration class for this provider
*
* @return string
*/
protected function get_integration_class(): string
{
return Jqhtml_BundleIntegration::class;
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Integrations\Jqhtml;
use Illuminate\View\Component;
use RuntimeException;
use App\RSpade\Integrations\Jqhtml\Jqhtml;
/**
* Generic Blade component for rendering jqhtml components
*
* Usage: <x-jqhtml component="User_Card" :args="['name' => 'Jim']" />
*/
#[Instantiatable]
class Jqhtml_View_Component extends Component
{
public string $component;
public array $args;
/**
* Create a new component instance
*
* @param string $component The jqhtml component name (e.g., 'User_Card')
* @param array $args Component arguments
*/
public function __construct(string $component, array $args = [])
{
// Validate component name starts with uppercase
if (!ctype_upper($component[0])) {
throw new RuntimeException(
"JQHTML component name '{$component}' must start with an uppercase letter. " .
'This is a hard requirement of the jqhtml library.'
);
}
$this->component = $component;
$this->args = $args;
}
/**
* Get the view / contents that represent the component
*
* @return \Illuminate\Contracts\View\View|string
*/
public function render()
{
// Use the existing Jqhtml helper to render the component
return Jqhtml::component($this->component, $this->args);
}
}

View File

@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JQHTML Compilation Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 40px auto;
padding: 0 20px;
}
.error-header {
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 8px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.error-title {
color: #e74c3c;
font-size: 24px;
margin: 0 0 10px 0;
font-weight: 600;
}
.error-file {
color: #666;
font-size: 14px;
margin: 10px 0;
font-family: 'Courier New', monospace;
}
.error-location {
color: #999;
font-size: 13px;
}
.error-message {
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.error-message h3 {
color: #555;
font-size: 16px;
margin: 0 0 15px 0;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.error-text {
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
padding: 20px;
background: #f8f8f8;
border-radius: 4px;
border-left: 4px solid #e74c3c;
color: #444;
}
.error-context {
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.error-context h3 {
color: #555;
font-size: 16px;
margin: 0 0 15px 0;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.code-context {
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
background: #2d2d2d;
color: #f8f8f2;
padding: 20px;
border-radius: 4px;
overflow-x: auto;
white-space: pre;
}
.error-line {
background: rgba(231, 76, 60, 0.2);
display: inline-block;
width: 100%;
padding: 0 5px;
margin: 0 -5px;
}
.line-number {
color: #999;
display: inline-block;
width: 40px;
text-align: right;
padding-right: 10px;
border-right: 1px solid #444;
margin-right: 10px;
}
.error-suggestion {
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.error-suggestion h3 {
color: #27ae60;
font-size: 16px;
margin: 0 0 15px 0;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.suggestion-text {
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
padding: 20px;
background: #f0f9f4;
border-radius: 4px;
border-left: 4px solid #27ae60;
color: #2c5f2d;
}
.error-trace {
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.error-trace h3 {
color: #555;
font-size: 16px;
margin: 0 0 15px 0;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.trace-list {
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.8;
}
.trace-item {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.trace-item:last-child {
border-bottom: none;
}
.trace-number {
color: #999;
display: inline-block;
width: 30px;
}
.trace-file {
color: #666;
}
.trace-function {
color: #3498db;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="error-header">
<h1 class="error-title">JQHTML Template Compilation Error</h1>
<div class="error-file">{{ $exception->getFile() }}</div>
@if($exception->getLine())
<div class="error-location">Line {{ $exception->getLine() }}@if(method_exists($exception, 'getColumn') && $exception->getColumn()), Column {{ $exception->getColumn() }}@endif</div>
@endif
</div>
<div class="error-message">
<h3>Error Message</h3>
<pre class="error-text">{{ $exception->getMessage() }}</pre>
</div>
@if(method_exists($exception, 'getContext') && $exception->getContext())
<div class="error-context">
<h3>Code Context</h3>
<pre class="code-context">{{ $exception->getContext() }}</pre>
</div>
@endif
@if(method_exists($exception, 'getSuggestion') && $exception->getSuggestion())
<div class="error-suggestion">
<h3>How to Fix</h3>
<pre class="suggestion-text">{{ $exception->getSuggestion() }}</pre>
</div>
@endif
@if(app()->environment('local', 'development'))
<div class="error-trace">
<h3>Stack Trace</h3>
<div class="trace-list">
@foreach($exception->getTrace() as $index => $trace)
@if($index < 10)
<div class="trace-item">
<span class="trace-number">#{{ $index }}</span>
<span class="trace-file">{{ $trace['file'] ?? 'unknown' }}:{{ $trace['line'] ?? '?' }}</span>
@if(isset($trace['class']))
<span class="trace-function">{{ $trace['class'] }}{{ $trace['type'] ?? '::' }}{{ $trace['function'] }}()</span>
@elseif(isset($trace['function']))
<span class="trace-function">{{ $trace['function'] }}()</span>
@endif
</div>
@endif
@endforeach
</div>
</div>
@endif
</div>
</body>
</html>

View File

@@ -0,0 +1,546 @@
<?php
namespace App\RSpade\Integrations\Scss;
use App\RSpade\Core\Bundle\BundleProcessor_Abstract;
/**
* ScssProcessor - Compiles SCSS files to CSS using Node.js sass compiler
*
* This processor:
* 1. Collects all .scss files in bundle order
* 2. Creates a master file with @import directives
* 3. Compiles using Node.js sass with source maps
* 4. Returns compiled CSS for bundle inclusion
*/
class Scss_BundleProcessor extends BundleProcessor_Abstract
{
/**
* Temporary directory for SCSS compilation
*/
protected static $temp_dir = null;
/**
* Collected SCSS files in order
*/
protected static $scss_files = [];
/**
* Get processor name
*/
public static function get_name(): string
{
return 'scss';
}
/**
* Get file extensions this processor handles
*/
public static function get_extensions(): array
{
return ['scss', 'sass'];
}
/**
* Process multiple SCSS files in batch
* Compiles SCSS files and appends the CSS output to the bundle
*/
public static function process_batch(array &$bundle_files): void
{
// Collect all SCSS files from the bundle
$scss_files = [];
foreach ($bundle_files as $path) {
$ext = pathinfo($path, PATHINFO_EXTENSION);
// Only process SCSS/SASS files
if (in_array($ext, ['scss', 'sass'])) {
// Validate the SCSS file for @import directives (unless it's a vendor file)
static::__validate_scss_file($path);
$scss_files[] = $path;
}
}
// If no SCSS files, nothing to do
if (empty($scss_files)) {
return;
}
// Generate cache key based on all SCSS files
$cache_key = static::_get_scss_cache_key($scss_files);
$temp_file = storage_path('rsx-tmp/scss_' . $cache_key . '.css');
// Check if we already have compiled output
$needs_compile = !file_exists($temp_file);
// If temp file exists, check if any source is newer
if (!$needs_compile && file_exists($temp_file)) {
$temp_mtime = filemtime($temp_file);
foreach ($scss_files as $source_file) {
if (filemtime($source_file) > $temp_mtime) {
$needs_compile = true;
break;
}
}
}
// Compile if needed
if ($needs_compile) {
console_debug('BUNDLE', 'Compiling ' . count($scss_files) . ' SCSS files');
// Create temp directory for compilation
$compile_dir = storage_path('rsx-tmp/scss_compile_' . uniqid());
if (!is_dir($compile_dir)) {
mkdir($compile_dir, 0755, true);
}
// Create master SCSS file with imports in order
$master_content = static::__create_master_scss($scss_files);
$master_file = $compile_dir . '/app.scss';
file_put_contents($master_file, $master_content);
// Compile SCSS to CSS using Node.js sass
static::__compile_scss($master_file, $temp_file, [
'minify' => app()->environment('production'),
'source_maps' => !app()->environment('production')
]);
// Clean up temp directory
@unlink($master_file);
@rmdir($compile_dir);
} else {
console_debug('BUNDLE', 'Using cached SCSS compilation: ' . basename($temp_file));
}
// Append the compiled CSS file to the bundle
$bundle_files[] = $temp_file;
}
/**
* Generate cache key for SCSS files
*
* Includes ALL files that might be imported by SCSS files.
* This ensures cache invalidation when ANY dependency changes.
*/
protected static function _get_scss_cache_key(array $scss_files): string
{
$all_files = [];
// Add the direct SCSS files
foreach ($scss_files as $file) {
$all_files[$file] = true;
}
// For each SCSS file, scan for potential @import dependencies
foreach ($scss_files as $file) {
$dependencies = static::_scan_scss_dependencies($file);
foreach ($dependencies as $dep) {
$all_files[$dep] = true;
}
}
// Generate hash from all files (direct + dependencies)
$hashes = [];
foreach (array_keys($all_files) as $file) {
if (file_exists($file)) {
$hashes[] = $file . ':' . filemtime($file) . ':' . filesize($file);
}
}
return substr(md5(implode('|', $hashes)), 0, 16);
}
/**
* Scan SCSS file for @import dependencies
*
* Recursively finds all files that might be imported.
* Returns array of absolute file paths.
*/
protected static function _scan_scss_dependencies(string $file, array &$visited = []): array
{
// Prevent infinite recursion
if (isset($visited[$file])) {
return [];
}
$visited[$file] = true;
if (!file_exists($file)) {
return [];
}
$dependencies = [];
$content = file_get_contents($file);
$base_dir = dirname($file);
// Remove comments to avoid false positives
$content = static::__remove_scss_comments($content);
// Match @import statements: @import "file" or @import 'file'
if (preg_match_all('/@import\s+["\']([^"\']+)["\']/', $content, $matches)) {
foreach ($matches[1] as $import_path) {
// Resolve the import path
$resolved = static::_resolve_scss_import($import_path, $base_dir);
if ($resolved && file_exists($resolved)) {
$dependencies[] = $resolved;
// Recursively scan this file's dependencies
$nested = static::_scan_scss_dependencies($resolved, $visited);
$dependencies = array_merge($dependencies, $nested);
}
}
}
return $dependencies;
}
/**
* Resolve SCSS @import path to actual file
*
* SCSS import resolution rules:
* - Can omit .scss extension
* - Can import partials with _ prefix
* - Searches in load paths
*/
protected static function _resolve_scss_import(string $import_path, string $base_dir): ?string
{
// If it's an absolute path or starts with ~, skip (module import)
if ($import_path[0] === '/' || $import_path[0] === '~') {
return null;
}
// Possible file variations
$variations = [];
// Remove extension if present
$path_without_ext = preg_replace('/\.(scss|sass)$/', '', $import_path);
$dirname = dirname($path_without_ext);
$basename = basename($path_without_ext);
// Build variations
$variations[] = "{$base_dir}/{$path_without_ext}.scss";
$variations[] = "{$base_dir}/{$path_without_ext}.sass";
$variations[] = "{$base_dir}/{$dirname}/_{$basename}.scss";
$variations[] = "{$base_dir}/{$dirname}/_{$basename}.sass";
// Try each variation
foreach ($variations as $candidate) {
$normalized = realpath($candidate);
if ($normalized && file_exists($normalized)) {
return $normalized;
}
}
return null;
}
/**
* Pre-processing hook - prepare temp directory
*/
public static function before_processing(array $all_files, array $options = []): void
{
// Reset collected files
static::$scss_files = [];
// Create temp directory for compilation
static::$temp_dir = storage_path('rsx-tmp/scss_' . uniqid());
if (!is_dir(static::$temp_dir)) {
mkdir(static::$temp_dir, 0755, true);
}
}
/**
* Post-processing hook - compile all SCSS files together
*/
public static function after_processing(array $processed_files, array $options = []): array
{
if (empty(static::$scss_files)) {
return [];
}
// Create master SCSS file with imports in order
$master_content = static::__create_master_scss(static::$scss_files);
$master_file = static::$temp_dir . '/app.scss';
file_put_contents($master_file, $master_content);
// Compile SCSS to CSS using Node.js sass
$output_file = static::$temp_dir . '/app.css';
static::__compile_scss($master_file, $output_file, $options);
// Clean up temp directory (except output file)
@unlink($master_file);
// Return the compiled CSS file to be included in bundle
return [$output_file];
}
/**
* Create master SCSS file with imports
*/
protected static function __create_master_scss(array $scss_files): string
{
$imports = [];
$imports[] = "// Master SCSS file - Generated by ScssProcessor";
$imports[] = "// This file imports all SCSS files in the bundle in order";
$imports[] = "";
foreach ($scss_files as $file) {
// Get relative path from project root
$relative = str_replace(base_path() . '/', '', $file);
// Add import statement
// Use the actual file path for better source map support
$imports[] = "/* ============ START: {$relative} ============ */";
$imports[] = "@import " . json_encode($file) . ";";
$imports[] = "/* ============ END: {$relative} ============ */";
$imports[] = "";
}
return implode("\n", $imports);
}
/**
* Compile SCSS to CSS using Node.js sass
*/
protected static function __compile_scss(string $input_file, string $output_file, array $options): void
{
$is_production = $options['minify'] ?? false;
$source_maps = $options['source_maps'] ?? !$is_production;
// Create Node.js script for SCSS compilation
$script = static::__create_compile_script($input_file, $output_file, $is_production, $source_maps);
$script_file = dirname($input_file) . '/compile.js';
file_put_contents($script_file, $script);
// Run the compilation (set working directory to project root for node_modules access)
$command = 'cd ' . escapeshellarg(base_path()) . ' && node ' . escapeshellarg($script_file) . ' 2>&1';
$output = shell_exec($command);
// Check for errors
if (!file_exists($output_file)) {
throw new \RuntimeException("SCSS compilation failed: " . $output);
}
// Clean up script file
@unlink($script_file);
}
/**
* Create Node.js compilation script
*/
protected static function __create_compile_script(
string $input_file,
string $output_file,
bool $is_production,
bool $source_maps
): string {
$script = <<<'JS'
const sass = require('sass');
const fs = require('fs');
const path = require('path');
const inputFile = process.argv[2] || '%INPUT%';
const outputFile = process.argv[3] || '%OUTPUT%';
const isProduction = %PRODUCTION%;
const enableSourceMaps = %SOURCEMAPS%;
const basePath = '%BASEPATH%';
async function compile() {
try {
// Compile SCSS
const result = sass.compile(inputFile, {
style: isProduction ? 'compressed' : 'expanded',
sourceMap: enableSourceMaps,
sourceMapIncludeSources: true,
verbose: !isProduction, // Show all deprecation warnings in dev mode
loadPaths: [
path.dirname(inputFile),
basePath + '/rsx',
basePath + '/rsx/styles',
basePath + '/resources/sass',
basePath + '/node_modules'
]
});
let cssContent = result.css;
// Add inline source map if enabled
// Using embedded source maps for better debugging experience
if (enableSourceMaps && result.sourceMap) {
// Add file boundaries in expanded mode for easier debugging
if (!isProduction) {
cssContent = cssContent.replace(/\/\* ============ START: (.+?) ============ \*\//g,
'\n/* ======= FILE: $1 ======= */\n');
cssContent = cssContent.replace(/\/\* ============ END: .+? ============ \*\//g, '');
}
// Fix sourcemap paths to be relative to project root and remove file:// protocol
const sourceMap = result.sourceMap;
sourceMap.sourceRoot = ''; // Use relative paths
// Clean up source paths (but don't filter - keep all sources to preserve mapping indices)
sourceMap.sources = result.sourceMap.sources.map(source => {
let cleanedSource = source;
// Remove file:// protocol if present
// file:///path means file (protocol) + :// (separator) + /path (absolute path)
// So file:///var/www/html should become /var/www/html
if (cleanedSource.startsWith('file:///')) {
cleanedSource = '/' + cleanedSource.substring(8); // Remove 'file:///' and add back the leading /
} else if (cleanedSource.startsWith('file://')) {
cleanedSource = cleanedSource.substring(7); // Remove 'file://' (non-standard)
}
// Make paths relative to project root
if (cleanedSource.startsWith(basePath + '/')) {
cleanedSource = cleanedSource.substring(basePath.length + 1);
} else if (cleanedSource.startsWith(basePath)) {
cleanedSource = cleanedSource.substring(basePath.length);
if (cleanedSource.startsWith('/')) {
cleanedSource = cleanedSource.substring(1);
}
}
return cleanedSource;
});
const sourceMapBase64 = Buffer.from(JSON.stringify(sourceMap)).toString('base64');
cssContent += '\n/*# sourceMappingURL=data:application/json;charset=utf-8;base64,' + sourceMapBase64 + ' */';
}
// Write output
fs.writeFileSync(outputFile, cssContent);
console.log('SCSS compilation successful');
// If production, also run postcss for additional optimization
if (isProduction) {
await optimizeWithPostCSS(outputFile);
}
} catch (error) {
console.error('SCSS compilation error:', error.message);
process.exit(1);
}
}
async function optimizeWithPostCSS(file) {
try {
const postcss = require('postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano');
const css = fs.readFileSync(file, 'utf8');
const result = await postcss([
autoprefixer(),
cssnano({
preset: ['default', {
discardComments: {
removeAll: true,
},
}]
})
]).process(css, {
from: file,
to: file,
map: enableSourceMaps ? { inline: true, sourcesContent: true } : false
});
fs.writeFileSync(file, result.css);
console.log('PostCSS optimization complete');
} catch (error) {
console.error('PostCSS optimization failed:', error.message);
// Don't fail the build if postcss fails
}
}
compile();
JS;
// Replace placeholders
$script = str_replace('%INPUT%', $input_file, $script);
$script = str_replace('%OUTPUT%', $output_file, $script);
$script = str_replace('%PRODUCTION%', $is_production ? 'true' : 'false', $script);
$script = str_replace('%SOURCEMAPS%', $source_maps ? 'true' : 'false', $script);
$script = str_replace('%BASEPATH%', base_path(), $script);
return $script;
}
/**
* Get processor priority (run early to process SCSS before CSS concatenation)
*/
public static function get_priority(): int
{
return 100; // High priority - process before other CSS processors
}
/**
* Validate that non-vendor SCSS files don't contain @import directives
*
* @param string $file_path The path to the SCSS file
* @throws \RuntimeException If a non-vendor file contains @import directives
*/
protected static function __validate_scss_file(string $file_path): void
{
// Check if this is a vendor file (contains '/vendor/' in the path)
$relative_path = str_replace(base_path() . '/', '', $file_path);
$is_vendor_file = str_contains($relative_path, '/vendor/');
// Vendor files are allowed to have @import directives
if ($is_vendor_file) {
return;
}
// Read the file content
if (!file_exists($file_path)) {
throw new \RuntimeException("SCSS file not found: {$file_path}");
}
$content = file_get_contents($file_path);
// Check for @import directives (must handle comments properly)
// First remove comments to avoid false positives
$content_without_comments = static::__remove_scss_comments($content);
// Now check for @import directives
if (preg_match('/@import\s+["\']/', $content_without_comments)) {
// Extract the actual @import lines for the error message
preg_match_all('/@import\s+["\'][^"\']+["\']/', $content, $imports);
$import_list = implode("\n ", $imports[0]);
throw new \RuntimeException(
"SCSS file '{$relative_path}' contains @import directives, which are not allowed in non-vendor files.\n\n" .
"Found imports:\n {$import_list}\n\n" .
"Why this is not allowed:\n" .
"- @import directives bypass the bundle system's dependency management\n" .
"- Files should be explicitly included in bundle definitions instead\n" .
"- This ensures proper load order and prevents missing dependencies\n\n" .
"How to fix:\n" .
"1. Remove the @import directives from this file\n" .
"2. Add the imported files directly to your bundle's include list\n" .
"3. Example: include: ['rsx/styles/variables.scss', 'rsx/app/mymodule/styles.scss']\n\n" .
"Exception for vendor files:\n" .
"- Files in 'vendor' directories CAN use @import (e.g., Bootstrap's SCSS)\n" .
"- These are third-party files that manage their own dependencies\n" .
"- Include only the main vendor entry file in your bundle"
);
}
}
/**
* Remove comments from SCSS content to avoid false positives in validation
*
* @param string $content The SCSS content
* @return string The content without comments
*/
protected static function __remove_scss_comments(string $content): string
{
// Remove single-line comments (// ...)
$content = preg_replace('#//.*?$#m', '', $content);
// Remove multi-line comments (/* ... */)
$content = preg_replace('#/\*.*?\*/#s', '', $content);
return $content;
}
}

View File

@@ -0,0 +1,289 @@
<?php
namespace App\RSpade\Integrations\Scss;
use App\RSpade\Core\Manifest\ManifestModule_Abstract;
/**
* Module for processing SCSS/CSS files in the manifest
*/
class Scss_ManifestModule extends ManifestModule_Abstract
{
/**
* Get file extensions this module handles
*/
public function handles(): array
{
return ['scss', 'sass', 'css'];
}
/**
* Get processing priority
*/
public function priority(): int
{
return 1000; // Lowest priority, process after all other modules including Jqhtml
}
/**
* Process a SCSS/CSS file and extract metadata
*
* For SCSS files, also detects if the file has a single top-level class selector
* that matches a Blade view ID, JavaScript class extending Jqhtml_Component,
* or jqhtml template ID in the manifest.
*/
public function process(string $file_path, array $metadata): array
{
$content = file_get_contents($file_path);
$extension = pathinfo($file_path, PATHINFO_EXTENSION);
$metadata['type'] = 'stylesheet';
$metadata['format'] = $extension;
// Extract imports
$imports = [];
// SCSS/Sass @import
if (preg_match_all('/@import\s+[\'"]([^\'"]+)[\'"];/', $content, $matches)) {
$imports = array_merge($imports, $matches[1]);
}
// CSS @import
if (preg_match_all('/@import\s+url\s*\(\s*[\'"]?([^\'")\s]+)[\'"]?\s*\)/', $content, $matches)) {
$imports = array_merge($imports, $matches[1]);
}
// SCSS/Sass @use
if (preg_match_all('/@use\s+[\'"]([^\'"]+)[\'"]/', $content, $matches)) {
$imports = array_merge($imports, $matches[1]);
}
if (!empty($imports)) {
$metadata['imports'] = array_unique($imports);
}
// Extract variables (SCSS/Sass)
if ($extension === 'scss' || $extension === 'sass') {
$variables = [];
if (preg_match_all('/\$([a-zA-Z_][\w-]*)\s*:/', $content, $matches)) {
$variables = $matches[1];
}
if (!empty($variables)) {
$metadata['variables'] = array_unique($variables);
}
}
// Extract mixins (SCSS/Sass)
if ($extension === 'scss' || $extension === 'sass') {
$mixins = [];
if (preg_match_all('/@mixin\s+([a-zA-Z_][\w-]*)/', $content, $matches)) {
$mixins = $matches[1];
}
if (!empty($mixins)) {
$metadata['mixins'] = array_unique($mixins);
}
}
// Extract functions (SCSS/Sass)
if ($extension === 'scss' || $extension === 'sass') {
$functions = [];
if (preg_match_all('/@function\s+([a-zA-Z_][\w-]*)/', $content, $matches)) {
$functions = $matches[1];
}
if (!empty($functions)) {
$metadata['functions'] = array_unique($functions);
}
}
// Extract extends/placeholders (SCSS/Sass)
if ($extension === 'scss' || $extension === 'sass') {
$placeholders = [];
if (preg_match_all('/%([a-zA-Z_][\w-]*)/', $content, $matches)) {
$placeholders = $matches[1];
}
if (!empty($placeholders)) {
$metadata['placeholders'] = array_unique($placeholders);
}
}
// Extract main selectors (top-level classes/IDs)
$selectors = [];
// Remove comments to avoid false positives
$clean_content = preg_replace('/\/\*.*?\*\//s', '', $content);
$clean_content = preg_replace('/\/\/.*$/m', '', $clean_content);
// Extract class selectors
if (preg_match_all('/^\.([a-zA-Z_][\w-]*)/m', $clean_content, $matches)) {
foreach ($matches[1] as $class) {
$selectors[] = '.' . $class;
}
}
// Extract ID selectors
if (preg_match_all('/^#([a-zA-Z_][\w-]*)/m', $clean_content, $matches)) {
foreach ($matches[1] as $id) {
$selectors[] = '#' . $id;
}
}
if (!empty($selectors)) {
$metadata['selectors'] = array_unique($selectors);
}
// Check for single top-level class selector pattern for SCSS files
if ($extension === 'scss') {
$this->detect_scss_id($clean_content, $metadata);
}
// Check if it's a partial (starts with underscore)
$filename = basename($file_path);
if (str_starts_with($filename, '_')) {
$metadata['is_partial'] = true;
}
// Determine scope based on path
$relative_path = str_replace(base_path() . '/', '', $file_path);
$metadata['relative_path'] = $relative_path;
if (str_contains($relative_path, '/pages/')) {
$metadata['scope'] = 'page';
} elseif (str_contains($relative_path, '/components/')) {
$metadata['scope'] = 'component';
} elseif (str_contains($relative_path, '/layouts/')) {
$metadata['scope'] = 'layout';
} elseif (str_contains($relative_path, '/utilities/') || str_contains($relative_path, '/utils/')) {
$metadata['scope'] = 'utility';
} elseif (str_contains($relative_path, '/base/') || str_contains($relative_path, '/foundation/')) {
$metadata['scope'] = 'base';
} else {
$metadata['scope'] = 'general';
}
// Check for media queries
if (preg_match_all('/@media\s+([^{]+)/', $content, $matches)) {
$media_queries = [];
foreach ($matches[1] as $query) {
$query = trim($query);
if (str_contains($query, 'min-width')) {
$media_queries[] = 'responsive';
}
if (str_contains($query, 'print')) {
$media_queries[] = 'print';
}
if (str_contains($query, 'prefers-color-scheme')) {
$media_queries[] = 'dark-mode';
}
}
if (!empty($media_queries)) {
$metadata['media_features'] = array_unique($media_queries);
}
}
// Check for CSS custom properties (CSS variables)
$css_vars = [];
if (preg_match_all('/--([a-zA-Z][\w-]*)/', $content, $matches)) {
$css_vars = $matches[1];
}
if (!empty($css_vars)) {
$metadata['css_variables'] = array_unique($css_vars);
}
// Check for Bootstrap usage
if (preg_match('/\.(btn|col-|row|container|navbar|modal|card|form-control)/', $content)) {
$metadata['uses_bootstrap'] = true;
}
// Check for Font Awesome usage
if (preg_match('/\.(fa-|fas|far|fab|fal|fad)/', $content)) {
$metadata['uses_fontawesome'] = true;
}
return $metadata;
}
/**
* Detect if SCSS file has a single top-level class that qualifies as an ID
*
* The SCSS file gets an 'id' if:
* 1. All rules are contained within a single top-level class selector
* 2. The class name matches a Blade view ID, JS class extending Jqhtml_Component, or jqhtml template
* 3. No other SCSS file already has this ID
*/
protected function detect_scss_id(string $clean_content, array &$metadata): void
{
// Remove all whitespace and newlines for easier parsing
$compact = preg_replace('/\s+/', ' ', trim($clean_content));
// Check if content starts with a single class selector and everything is inside it
// Pattern: .ClassName { ... everything ... }
if (!preg_match('/^\.([A-Z][a-zA-Z0-9_]+)\s*\{(.*)\}\s*$/', $compact, $matches)) {
return;
}
$class_name = $matches[1];
$inner_content = $matches[2];
// Verify there are no other top-level rules by checking for unmatched closing braces
// Count opening and closing braces in the inner content
$open_braces = substr_count($inner_content, '{');
$close_braces = substr_count($inner_content, '}');
// If braces are balanced, everything is contained within the main selector
if ($open_braces !== $close_braces) {
return;
}
// Now check if this class name matches something in the manifest
// During build, we need to access the in-memory manifest data
// The get_all() method returns the cached data, not the in-progress build
// We need a different approach - access the static data directly
// Get access to the Manifest class's internal data using reflection
$reflection = new \ReflectionClass(\App\RSpade\Core\Manifest\Manifest::class);
$data_property = $reflection->getProperty('data');
$data_property->setAccessible(true);
$manifest_state = $data_property->getValue();
if (!isset($manifest_state['data']['files'])) {
return;
}
$manifest_data = $manifest_state['data']['files'];
$found_match = false;
$scss_id_already_exists = false;
foreach ($manifest_data as $file_data) {
// Check if another SCSS file already has this ID
if (isset($file_data['id']) && $file_data['id'] === $class_name &&
isset($file_data['extension']) && $file_data['extension'] === 'scss') {
$scss_id_already_exists = true;
break;
}
// Check for matching ID in Blade view or jqhtml template
if (isset($file_data['id']) && $file_data['id'] === $class_name) {
$found_match = true;
}
// Check for JavaScript class extending Jqhtml_Component
if (isset($file_data['extension']) && $file_data['extension'] === 'js' &&
isset($file_data['class']) && $file_data['class'] === $class_name &&
isset($file_data['extends']) && $file_data['extends'] === 'Jqhtml_Component') {
$found_match = true;
}
}
// Only set ID if we found a match and no other SCSS has this ID
if ($found_match && !$scss_id_already_exists) {
$metadata['id'] = $class_name;
}
}
}