From 8c8fb8e902cc0714f9fe6ada12369f9cdf2d4efa Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Oct 2025 17:18:10 +0000 Subject: [PATCH] Mark PHP version compatibility fallback as legitimate in Php_Fixer Add public directory asset support to bundle system Fix PHP Fixer to replace ALL Rsx\ FQCNs with simple class names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../LayoutLocalAssets_CodeQualityRule.php | 223 ++++++++++++++++++ app/RSpade/Core/Bundle/BundleCompiler.php | 51 ++++ .../Core/Bundle/Rsx_Bundle_Abstract.php | 12 + app/RSpade/Core/Dispatch/AssetHandler.php | 163 +++++++------ app/RSpade/Core/Manifest/Manifest.php | 90 ++++++- app/RSpade/Core/PHP/Php_Fixer.php | 169 +++++++++---- app/RSpade/man/bundle_api.txt | 71 ++++++ docs/CLAUDE.dist.md | 62 ++++- 8 files changed, 720 insertions(+), 121 deletions(-) create mode 100755 app/RSpade/CodeQuality/Rules/Blade/LayoutLocalAssets_CodeQualityRule.php diff --git a/app/RSpade/CodeQuality/Rules/Blade/LayoutLocalAssets_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/Blade/LayoutLocalAssets_CodeQualityRule.php new file mode 100755 index 000000000..dc31c3e32 --- /dev/null +++ b/app/RSpade/CodeQuality/Rules/Blade/LayoutLocalAssets_CodeQualityRule.php @@ -0,0 +1,223 @@ + (layouts) + if (!str_contains($contents, ' $line) { + $line_number = $line_num + 1; + + // Check for ]*href=["\'](\/[^"\']*)["\'][^>]*>/i', $line, $matches)) { + $href = $matches[1]; + + // Skip if it's a CDN/external URL (contains http) + if (str_contains(strtolower($line), 'http')) { + continue; + } + + $violations[] = [ + 'type' => 'local_css', + 'line' => $line_number, + 'code' => trim($line), + 'path' => $href + ]; + } + + // Check for ") . " + +KEY BENEFITS: +- Automatic filemtime() cache-busting on every page load +- Proper asset ordering (CDN assets → Public assets → Compiled bundles) +- Redis-cached path resolution for performance +- Ambiguity detection prevents multiple files with same path + +BUNDLE INCLUDE SYNTAX: +- Prefix with /public/ for static assets from public/ directories +- Path after /public/ is searched across ALL public/ directories in rsx/ +- Example: '/public/vendor/css/core.css' resolves to 'rsx/public/vendor/css/core.css' + +CACHE-BUSTING: +- Bundle generates tags with for fresh timestamps +- No need to manually manage version parameters +- Updates automatically when file changes + +For complete documentation: + php artisan rsx:man bundle_api + (See PUBLIC ASSET INCLUDES section)"; + } +} diff --git a/app/RSpade/Core/Bundle/BundleCompiler.php b/app/RSpade/Core/Bundle/BundleCompiler.php index 5ed0a5b1a..28b366088 100755 --- a/app/RSpade/Core/Bundle/BundleCompiler.php +++ b/app/RSpade/Core/Bundle/BundleCompiler.php @@ -43,6 +43,12 @@ class BundleCompiler */ protected array $cdn_assets = ['js' => [], 'css' => []]; + /** + * Public directory assets (served via AssetHandler with filemtime cache-busting) + * Format: ['js' => [['url' => '/path/to/file.js', 'full_path' => '/full/filesystem/path']], ...] + */ + protected array $public_assets = ['js' => [], 'css' => []]; + /** * Cache keys for vendor/app */ @@ -175,6 +181,14 @@ class BundleCompiler $result['cdn_css'] = $this->cdn_assets['css']; } + // Add public directory assets + if (!empty($this->public_assets['js'])) { + $result['public_js'] = $this->public_assets['js']; + } + if (!empty($this->public_assets['css'])) { + $result['public_css'] = $this->public_assets['css']; + } + // Add bundle file paths for development if (!$this->is_production) { if (isset($outputs['vendor_js'])) { @@ -501,6 +515,43 @@ class BundleCompiler } $this->resolved_includes[$include_key] = true; + // Check for /public/ prefix - static assets from public directories + if (is_string($item) && str_starts_with($item, '/public/')) { + $relative_path = substr($item, 8); // Strip '/public/' prefix + + try { + // Resolve via AssetHandler (with Redis caching) + $full_path = \App\RSpade\Core\Dispatch\AssetHandler::find_public_asset($relative_path); + + // Determine file type + $extension = strtolower(pathinfo($relative_path, PATHINFO_EXTENSION)); + + if ($extension === 'js') { + $this->public_assets['js'][] = [ + 'url' => '/' . $relative_path, + 'full_path' => $full_path + ]; + } elseif ($extension === 'css') { + $this->public_assets['css'][] = [ + 'url' => '/' . $relative_path, + 'full_path' => $full_path + ]; + } else { + throw new RuntimeException( + "Public asset must be .js or .css file: {$item}\n" . + "Only JavaScript and CSS files can be included via /public/ prefix." + ); + } + + return; + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { + throw new RuntimeException( + "Failed to resolve public asset: {$item}\n" . + $e->getMessage() + ); + } + } + // Check bundle aliases and resolve to actual class $bundle_aliases = config('rsx.bundle_aliases', []); if (is_string($item) && isset($bundle_aliases[$item])) { diff --git a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php index 0d9b35f65..29071014c 100755 --- a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php +++ b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php @@ -391,6 +391,12 @@ abstract class Rsx_Bundle_Abstract $html[] = $tag; } + // Add public directory CSS (with filemtime cache-busting) + $public_css = $compiled['public_css'] ?? []; + foreach ($public_css as $asset) { + $html[] = ''; + } + // Add JS: jQuery first, then others foreach (array_merge($jquery_js, $other_js) as $asset) { $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'])) { diff --git a/app/RSpade/Core/Dispatch/AssetHandler.php b/app/RSpade/Core/Dispatch/AssetHandler.php index fedeb4f17..cb63ac589 100755 --- a/app/RSpade/Core/Dispatch/AssetHandler.php +++ b/app/RSpade/Core/Dispatch/AssetHandler.php @@ -9,6 +9,7 @@ namespace App\RSpade\Core\Dispatch; use Illuminate\Http\Request; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Redis; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -215,14 +216,88 @@ class AssetHandler // Set additional security headers static::__set_security_headers($response, $mime_type); - // Enable gzip if supported - if (static::__should_compress($mime_type)) { - $response->headers->set('Content-Encoding', 'gzip'); - } - return $response; } - + + /** + * Find a public asset by relative path with Redis caching + * + * Resolves paths like "sneat/css/demo.css" to full filesystem paths like + * "rsx/public/sneat/css/demo.css" by scanning all public/ directories. + * + * Results are cached in Redis indefinitely. Cached paths are validated + * before use - if file no longer exists, cache is invalidated and re-scan occurs. + * + * @param string $relative_path Relative path like "sneat/css/demo.css" + * @return string Full filesystem path + * @throws \Symfony\Component\HttpKernel\Exception\HttpException If not found or ambiguous + */ + public static function find_public_asset(string $relative_path): string + { + // Ensure directories are discovered + static::__ensure_directories_discovered(); + + // Sanitize the path + $relative_path = static::__sanitize_path($relative_path); + + // Check Redis cache first + $cache_key = 'rspade:public_asset:' . $relative_path; + $cached_path = Redis::get($cache_key); + + if ($cached_path) { + // Verify cached file still exists + if (File::exists($cached_path) && File::isFile($cached_path)) { + return $cached_path; + } + + // Stale cache - invalidate and re-scan + Redis::del($cache_key); + } + + // NEVER serve PHP files under any circumstances + $extension = strtolower(pathinfo($relative_path, PATHINFO_EXTENSION)); + if ($extension === 'php') { + throw new HttpException(403, 'PHP files cannot be served as static assets'); + } + + // Scan all public directories for matches + $matches = []; + + foreach (static::$public_directories as $module => $directory) { + $full_path = $directory . '/' . $relative_path; + + if (File::exists($full_path) && File::isFile($full_path)) { + // Check exclusion rules + if (static::__is_file_excluded($full_path, $relative_path)) { + throw new HttpException(403, 'Access to this file is forbidden'); + } + $matches[] = $full_path; + } + } + + // Check for ambiguous matches + if (count($matches) > 1) { + // Show first two matches in error + $first_two = array_slice($matches, 0, 2); + throw new HttpException( + 500, + "Ambiguous public asset request: '{$relative_path}' matches multiple files: '" . + implode("', '", $first_two) . "'" + ); + } + + // Check for no matches + if (count($matches) === 0) { + throw new NotFoundHttpException("Public asset not found: {$relative_path}"); + } + + // Single match - cache and return + $resolved_path = $matches[0]; + Redis::set($cache_key, $resolved_path); + + return $resolved_path; + } + /** * Ensure directories are discovered (lazy initialization) */ @@ -294,52 +369,22 @@ class AssetHandler /** * Find asset file in public directories * + * Wrapper around find_public_asset() that returns null instead of throwing + * NotFoundHttpException for backward compatibility with existing code. + * * @param string $path * @return string|null Full file path or null if not found - * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * @throws \Symfony\Component\HttpKernel\Exception\HttpException For PHP files, exclusions, or ambiguous matches */ protected static function __find_asset_file($path) { - // NEVER serve PHP files under any circumstances - $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); - if ($extension === 'php') { - throw new HttpException(403, 'PHP files cannot be served as static assets'); + try { + return static::find_public_asset($path); + } catch (NotFoundHttpException $e) { + // Not found - return null for backward compatibility + return null; } - - // Try each public directory - foreach (static::$public_directories as $module => $directory) { - $full_path = $directory . '/' . $path; - - if (File::exists($full_path) && File::isFile($full_path)) { - // Check exclusion rules before returning - if (static::__is_file_excluded($full_path, $path)) { - throw new HttpException(403, 'Access to this file is forbidden'); - } - return $full_path; - } - } - - // Check if path includes module prefix (e.g., "admin/css/style.css") - $parts = explode('/', $path, 2); - - if (count($parts) === 2) { - $module = $parts[0]; - $asset_path = $parts[1]; - - if (isset(static::$public_directories[$module])) { - $full_path = static::$public_directories[$module] . '/' . $asset_path; - - if (File::exists($full_path) && File::isFile($full_path)) { - // Check exclusion rules before returning - if (static::__is_file_excluded($full_path, $asset_path)) { - throw new HttpException(403, 'Access to this file is forbidden'); - } - return $full_path; - } - } - } - - return null; + // Let other exceptions (403, 500) bubble up } /** @@ -674,32 +719,6 @@ class AssetHandler } } - /** - * Check if content should be compressed - * - * @param string $mime_type - * @return bool - */ - protected static function __should_compress($mime_type) - { - // Compress text-based content - $compressible = [ - 'text/', - 'application/javascript', - 'application/json', - 'application/xml', - 'image/svg+xml' - ]; - - foreach ($compressible as $type) { - if (str_starts_with($mime_type, $type)) { - return true; - } - } - - return false; - } - /** * Get discovered public directories * diff --git a/app/RSpade/Core/Manifest/Manifest.php b/app/RSpade/Core/Manifest/Manifest.php index ee792714f..02f79ac01 100755 --- a/app/RSpade/Core/Manifest/Manifest.php +++ b/app/RSpade/Core/Manifest/Manifest.php @@ -1383,7 +1383,34 @@ class Manifest // Validate class names are unique. static::__check_unique_base_class_names(); - // Apply Php_Fixer to all PHP files in rsx/ and app/RSpade/ before parsing + // ================================================================================== + // PHP FIXER INTEGRATION POINT + // ================================================================================== + // This is where automatic code fixes are applied before Phase 2 parsing. + // + // WHAT PHP_FIXER DOES: + // 1. Fixes namespaces to match file paths + // 2. Removes/rebuilds use statements (strips Rsx\ and App\RSpade\ prefixes) + // 3. Replaces FQCNs like \Rsx\Models\User_Model with simple names User_Model + // 4. Adds #[Relationship] attributes to model ORM methods + // 5. Removes leading backslashes from attributes: #[\Route] → #[Route] + // + // SMART REBUILDING: + // - Tracks SHA1 hash of all class structures (ClassName:ParentClass) + // - If structure changed: Fixes ALL files (cascading updates needed) + // - If structure unchanged: Fixes ONLY $files_to_process (incremental) + // + // WHY BEFORE PHASE 2: + // - Phase 2 parses metadata from file content + // - If we fix AFTER parsing, manifest would have old/incorrect metadata + // - By fixing BEFORE, we parse the corrected content + // + // RE-PARSING LOOP BELOW: + // - If Php_Fixer modified files, we MUST re-parse them + // - This updates manifest with corrected namespace/class/FQCN data + // - Without this, manifest would reference old class locations + // ================================================================================== + $php_fixer_modified_files = []; if (!app()->environment('production')) { $php_fixer_modified_files = static::__run_php_fixer($files_to_process); @@ -2282,6 +2309,29 @@ class Manifest * Run Php_Fixer on all PHP files in rsx/ and app/RSpade/ * Called before Phase 2 parsing to ensure all files are fixed * + * SMART REBUILD STRATEGY: + * This method implements an intelligent rebuild strategy to avoid unnecessary file writes: + * + * 1. STRUCTURE HASH: Creates SHA1 hash of "ClassName:ParentClass" for ALL classes + * - Detects when classes are added, removed, renamed, or inheritance changes + * + * 2. FULL REBUILD TRIGGERS: + * - New class added (may need new use statements elsewhere) + * - Class renamed (all references need updating) + * - Inheritance changed (may affect use statement resolution) + * → When triggered: Fix ALL PHP files in rsx/ and app/RSpade/ + * + * 3. INCREMENTAL REBUILD: + * - Structure hash unchanged (no new/renamed classes) + * - Only fixes files that actually changed on disk + * → More efficient, avoids touching unchanged files + * + * WHY THIS MATTERS: + * - use statement management depends on knowing all available classes + * - FQCN replacement needs to check class name uniqueness + * - When class structure changes, files referencing those classes need updating + * - When structure stable, only changed files need processing + * * @param array $changed_files List of changed files from Phase 1 * @return array List of files that were modified by Php_Fixer */ @@ -2289,7 +2339,14 @@ class Manifest { $modified_files = []; - // Build hash array of all PHP classes to detect structural changes + // ================================================================================== + // STEP 1: BUILD CLASS STRUCTURE HASH + // ================================================================================== + // Create a fingerprint of ALL classes in the codebase. + // Format: "path/to/file.php" => "ClassName:ParentClass" + // This lets us detect when the class structure itself changes (not just file contents) + // ================================================================================== + $class_structure_hash_data = []; foreach (static::$data['data']['files'] as $file_path => $metadata) { @@ -2311,12 +2368,24 @@ class Manifest // Calculate hash of class structure $new_class_structure_hash = sha1(json_encode($class_structure_hash_data)); - // Check if class structure has changed + // ================================================================================== + // STEP 2: DECIDE REBUILD STRATEGY + // ================================================================================== + // Compare with previous hash to detect structural changes + // ================================================================================== + $previous_hash = static::$data['data']['php_fixer_hash'] ?? null; $structure_changed = ($previous_hash !== $new_class_structure_hash); if ($structure_changed) { - // Class structure changed - fix ALL PHP files in rsx/ and app/RSpade/ + // ================================================================================== + // FULL REBUILD: Class structure changed + // ================================================================================== + // When class structure changes, we MUST fix ALL files because: + // - New classes may be referenced in existing files → need new use statements + // - Renamed classes need all references updated + // - Inheritance changes may affect use statement resolution + // ================================================================================== $php_files_to_fix = []; foreach (static::$data['data']['files'] as $file_path => $metadata) { @@ -2340,10 +2409,19 @@ class Manifest } } - // Store updated hash + // Store updated hash for next rebuild comparison static::$data['data']['php_fixer_hash'] = $new_class_structure_hash; } else { - // Class structure unchanged - only fix changed PHP files with classes + // ================================================================================== + // INCREMENTAL REBUILD: Class structure unchanged + // ================================================================================== + // Only fix files that actually changed on disk. + // Safe because: + // - No new classes = no new use statements needed elsewhere + // - No renamed classes = no references to update + // - No inheritance changes = use statement resolution unchanged + // Result: Much faster, avoids touching 99% of files on typical edits + // ================================================================================== $php_files_to_fix = []; foreach ($changed_files as $file_path) { diff --git a/app/RSpade/Core/PHP/Php_Fixer.php b/app/RSpade/Core/PHP/Php_Fixer.php index 77445c89f..877eeba84 100755 --- a/app/RSpade/Core/PHP/Php_Fixer.php +++ b/app/RSpade/Core/PHP/Php_Fixer.php @@ -15,9 +15,57 @@ use RuntimeException; * Performs automatic fixes and enhancements to PHP source files during development: * - Auto-adds #[Relationship] attributes to model files * - Auto-updates namespaces based on file location - * - Other automatic code improvements + * - Removes/rebuilds use statements for Rsx\ and App\RSpade\ classes + * - Replaces FQCNs like \Rsx\Models\User_Model with simple names User_Model + * - Removes leading backslashes from attributes: #[\Route] → #[Route] * * Only runs in non-production environments to avoid modifying deployed code. + * + * ====================================================================================== + * HOW TO ADD NEW RULES + * ====================================================================================== + * + * TO ADD A NEW FIX: + * 1. Create a new private static method: __fix_your_feature($file_path, $content, $manifest) + * 2. Add it to the fix() method's sequential application list (line ~123) + * 3. Method signature: private static function __fix_*($file_path, $content, &$step_2_manifest_data): string + * 4. Return the modified content (or original if no changes) + * + * EXAMPLE SKELETON: + * ```php + * private static function __fix_rsx_fqcn($file_path, string $content, array &$step_2_manifest_data): string + * { + * // Only process files in rsx/ or app/RSpade/ + * if (!str_starts_with($file_path, 'rsx/') && !str_starts_with($file_path, 'app/RSpade/')) { + * return $content; + * } + * + * // Parse tokens for accurate replacement + * $tokens = token_get_all($content); + * + * // Find patterns to fix + * // Build modifications array with positions + * + * // Apply modifications (usually in reverse order to preserve positions) + * + * return $modified_content; + * } + * ``` + * + * IMPORTANT PATTERNS: + * - Always work with tokens for PHP syntax awareness + * - Build modifications array, then apply in reverse order (preserves positions) + * - Use step_2_manifest_data to look up class information + * - Return original content if no changes needed + * - Never modify files in production environment (checked by caller) + * + * MANIFEST DATA AVAILABLE: + * - static::$data['data']['files'] - All indexed files with metadata + * - Files processed earlier in Phase 2 have complete metadata + * - Current file NOT in manifest yet (being processed now) + * - See method docblock for fix() for complete manifest structure details + * + * ====================================================================================== */ class Php_Fixer { @@ -335,6 +383,32 @@ class Php_Fixer /** * Fix use statements - remove unnecessary ones, add missing ones * + * THREE-STEP PROCESS: + * + * STEP 1: Remove all Rsx\ and App\RSpade\ use statements + * - We'll rebuild these based on actual usage + * - Protects vendor/Laravel use statements (never removes) + * + * STEP 2: Replace ALL Rsx\ FQCNs with simple names + * - Converts: \Rsx\Models\User_Model::class → User_Model::class + * - Works for ALL classes in manifest (even non-unique names) + * - Relies on Step 3 to add disambiguating use statements + * + * STEP 3: Re-add use statements based on actual usage + * - Scans for simple class name references (from Step 2 replacements) + * - Looks up FQCNs in manifest + * - Adds: use Rsx\Models\User_Model; + * - Disambiguates non-unique class names automatically + * + * EXAMPLE TRANSFORMATION: + * Before: + * return $this->belongsTo(\Rsx\Models\User_Model::class, 'team_lead_id'); + * + * After: + * use Rsx\Models\User_Model; + * ... + * return $this->belongsTo(User_Model::class, 'team_lead_id'); + * * @param string $file_path Relative path from base_path() * @param string $content Current file content * @param array $step_2_manifest_data Manifest state during Phase 2 @@ -648,7 +722,10 @@ class Php_Fixer * Replace fully qualified Rsx class names with simple names * * This function finds \Rsx\Namespace\ClassName patterns and replaces them - * with just ClassName if that class exists uniquely (only one file with that class name) + * with just ClassName for ALL Rsx\ classes in manifest (even non-unique names). + * + * Step 3 of __fix_use_statements() will add the appropriate use statement, + * which disambiguates non-unique class names. * * @param string $content File content * @param array $step_2_manifest_data Manifest data @@ -656,15 +733,15 @@ class Php_Fixer */ private static function __replace_rsx_fqcn_with_simple_names(string $content, array &$step_2_manifest_data): string { - // First, build a map of simple class names to count occurrences - $class_name_counts = []; + // Build a set of all valid Rsx\ class names in manifest + $valid_rsx_classes = []; foreach ($step_2_manifest_data['data']['files'] ?? [] as $manifest_file => $metadata) { if (isset($metadata['class']) && !empty($metadata['class'])) { - $simple_name = $metadata['class']; - if (!isset($class_name_counts[$simple_name])) { - $class_name_counts[$simple_name] = 0; + // Check if this file is an Rsx\ class (in rsx/ directory) + if (str_starts_with($manifest_file, 'rsx/')) { + $simple_name = $metadata['class']; + $valid_rsx_classes[$simple_name] = true; } - $class_name_counts[$simple_name]++; } } @@ -681,8 +758,18 @@ class Php_Fixer $modifications = []; for ($i = 0; $i < count($tokens); $i++) { - // Look for namespace separator that starts a fully qualified name - if ($tokens[$i] === '\\' || (is_array($tokens[$i]) && $tokens[$i][0] === T_NS_SEPARATOR)) { + $fqcn = null; + $fqcn_start = null; + $fqcn_end = null; + + // PHP 8+ uses T_NAME_FULLY_QUALIFIED for complete FQCN like \Rsx\Models\User_Model + if (is_array($tokens[$i]) && defined('T_NAME_FULLY_QUALIFIED') && $tokens[$i][0] === T_NAME_FULLY_QUALIFIED) { + $fqcn = $tokens[$i][1]; + $fqcn_start = $i; + $fqcn_end = $i + 1; + } + // Fallback* for older PHP or partial namespaces + elseif ($tokens[$i] === '\\' || (is_array($tokens[$i]) && $tokens[$i][0] === T_NS_SEPARATOR)) { // Check if this starts \Rsx\ $fqcn_start = $i; $fqcn = '\\'; @@ -707,40 +794,42 @@ class Php_Fixer break; } } + $fqcn_end = $j; + } - // Check if this is an Rsx FQCN (ONLY process \Rsx\ namespaced classes) - if (str_starts_with($fqcn, '\\Rsx\\')) { - // Extract simple class name (last part after final \) - $parts = explode('\\', trim($fqcn, '\\')); - $simple_name = end($parts); + // Process FQCN if we found one + if ($fqcn && str_starts_with($fqcn, '\\Rsx\\')) { + // Extract simple class name (last part after final \) + $parts = explode('\\', trim($fqcn, '\\')); + $simple_name = end($parts); - // Only replace if this class name is UNIQUE (appears only once in manifest) - if (isset($class_name_counts[$simple_name]) && $class_name_counts[$simple_name] === 1) { - // Calculate the byte positions for replacement - $start_pos = 0; - for ($k = 0; $k < $fqcn_start; $k++) { - if (is_array($tokens[$k])) { - $start_pos += strlen($tokens[$k][1]); - } else { - $start_pos += strlen($tokens[$k]); - } + // Replace if this class exists in manifest (even if name is not unique) + // Step 3 will add the correct use statement to disambiguate + if (isset($valid_rsx_classes[$simple_name])) { + // Calculate the byte positions for replacement + $start_pos = 0; + for ($k = 0; $k < $fqcn_start; $k++) { + if (is_array($tokens[$k])) { + $start_pos += strlen($tokens[$k][1]); + } else { + $start_pos += strlen($tokens[$k]); } - - $length = 0; - for ($k = $fqcn_start; $k < $j; $k++) { - if (is_array($tokens[$k])) { - $length += strlen($tokens[$k][1]); - } else { - $length += strlen($tokens[$k]); - } - } - - $modifications[] = [ - 'start' => $start_pos, - 'length' => $length, - 'replacement' => $simple_name, - ]; } + + $length = 0; + for ($k = $fqcn_start; $k < $fqcn_end; $k++) { + if (is_array($tokens[$k])) { + $length += strlen($tokens[$k][1]); + } else { + $length += strlen($tokens[$k]); + } + } + + $modifications[] = [ + 'start' => $start_pos, + 'length' => $length, + 'replacement' => $simple_name, + ]; } } } diff --git a/app/RSpade/man/bundle_api.txt b/app/RSpade/man/bundle_api.txt index c0118ff7e..0017ae543 100755 --- a/app/RSpade/man/bundle_api.txt +++ b/app/RSpade/man/bundle_api.txt @@ -118,6 +118,77 @@ INCLUDE TYPES External resources: 'cdn:https://unpkg.com/library.js' + Public Directory Assets + Static assets from public/ directories with automatic cache-busting: + '/public/sneat/css/core.css' + '/public/sneat/js/helpers.js' + + These resolve to files in any public/ directory in rsx/. Resolution + cached in Redis for performance. Generates tags with filemtime() for + fresh cache-busting on each page render. + +PUBLIC ASSET INCLUDES + Bundles can include static assets from any public/ directory with + automatic cache-busting via filemtime(). + + SYNTAX + Prefix paths with /public/ in bundle includes: + + 'include' => [ + '/public/sneat/css/core.css', + '/public/sneat/js/helpers.js', + ] + + RESOLUTION + Path "sneat/css/demo.css" resolves to first match across all public/ + directories in manifest. Resolution cached in Redis indefinitely. + + Searches: + rsx/public/sneat/css/demo.css + rsx/app/admin/public/sneat/css/demo.css + rsx/theme/public/sneat/css/demo.css + ... (all public/ directories) + + OUTPUT + CSS: + JS: + + The filemtime() call executes on each page render, providing fresh + cache-busting timestamps without rebuilding bundles. + + ORDERING + Public assets output with CDN includes, before compiled bundle code. + Order preserved as listed in bundle definition: + + 1. CDN CSS assets + 2. Public directory CSS + 3. Compiled bundle CSS + 4. CDN JS assets + 5. Public directory JS + 6. Compiled bundle JS + + AMBIGUITY ERRORS + If multiple files match the same path, compilation fails: + + RuntimeException: Ambiguous public asset request: + 'sneat/css/demo.css' matches multiple files: + 'rsx/public/sneat/css/demo.css', + 'rsx/theme/public/sneat/css/demo.css' + + Solution: Use more specific paths or rename files to avoid conflicts. + + CACHING + - Path resolution cached in Redis indefinitely + - Cache validated on each use (file existence check) + - Stale cache automatically re-scanned + - filemtime() executes on each page render for cache-busting + + RESTRICTIONS + - Only .js and .css files allowed + - Must start with /public/ prefix + - Files must exist in a public/ directory + - No PHP files allowed (security) + BUNDLE RENDERING In Blade layouts/views: {!! Dashboard_Bundle::render() !!} diff --git a/docs/CLAUDE.dist.md b/docs/CLAUDE.dist.md index c600f2083..35246e15d 100644 --- a/docs/CLAUDE.dist.md +++ b/docs/CLAUDE.dist.md @@ -278,12 +278,15 @@ class Frontend_Bundle extends Rsx_Bundle_Abstract 'rsx/theme/variables.scss', // Order matters 'rsx/app/frontend', // Directory 'rsx/models', // For JS stubs + '/public/vendor/css/core.css', // Public directory asset (filemtime cache-busting) ], ]; } } ``` +Bundles support `/public/` prefix for including static assets from public directories with automatic cache-busting. + Auto-compiles on page reload in development. ```blade @@ -517,18 +520,66 @@ User_Model::create(['email' => $email]); ### Enums +**🔴 CRITICAL: Enum columns MUST be integers in both database and model definition** + +Enum columns store integer values in the database, NOT strings. The model definition maps those integers to constants and labels. + ```php +// ✅ CORRECT - Integer keys map to constants public static $enums = [ 'status_id' => [ 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active'], + 2 => ['constant' => 'STATUS_INACTIVE', 'label' => 'Inactive'], + 3 => ['constant' => 'STATUS_PENDING', 'label' => 'Pending'], + ], + 'priority_id' => [ + 1 => ['constant' => 'PRIORITY_LOW', 'label' => 'Low'], + 2 => ['constant' => 'PRIORITY_MEDIUM', 'label' => 'Medium'], + 3 => ['constant' => 'PRIORITY_HIGH', 'label' => 'High'], + ], +]; + +// ❌ WRONG - String keys are NOT allowed +public static $enums = [ + 'status' => [ // ❌ Column name should be status_id + 'active' => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active'], // ❌ String key + 'inactive' => ['constant' => 'STATUS_INACTIVE', 'label' => 'Inactive'], // ❌ String key ], ]; // Usage -$user->status_id = User_Model::STATUS_ACTIVE; +$user->status_id = User_Model::STATUS_ACTIVE; // Sets to 1 echo $user->status_label; // "Active" +echo $user->status_id; // 1 (integer) ``` +**Migration Requirements**: Enum columns must be INT(11), NEVER VARCHAR: + +```php +public function up() +{ + DB::statement(" + CREATE TABLE users ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + status_id INT(11) NOT NULL DEFAULT 1, -- ✅ CORRECT - Enum column + priority_id INT(11) NOT NULL DEFAULT 1, -- ✅ CORRECT - Enum column + is_active TINYINT(1) NOT NULL DEFAULT 1, -- Boolean field (0=false, 1=true) + INDEX idx_status_id (status_id), + INDEX idx_priority_id (priority_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + "); +} + +// ❌ WRONG - VARCHAR columns are NOT allowed for enums +CREATE TABLE users ( + status VARCHAR(20) NOT NULL DEFAULT 'active' -- ❌ WRONG - Use INT(11) instead +); +``` + +**Column Type Guidelines**: +- **INT(11)** - ALL enum columns use this type +- **TINYINT(1)** - Boolean fields ONLY (stores 0 or 1, treated as true/false in PHP) + ### Migrations **Forward-only, no rollbacks.** @@ -913,9 +964,11 @@ class User_Model extends Rsx_Model_Abstract protected $table = 'users'; protected $fillable = []; // Always empty - no mass assignment + // Enum columns - MUST use integer keys public static $enums = [ 'status_id' => [ 1 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active'], + 2 => ['constant' => 'STATUS_INACTIVE', 'label' => 'Inactive'], ], ]; } @@ -930,9 +983,12 @@ public function up() CREATE TABLE articles ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, - status_id TINYINT(1) NOT NULL DEFAULT 1, + status_id INT(11) NOT NULL DEFAULT 1, -- Enum column + priority_id INT(11) NOT NULL DEFAULT 1, -- Enum column + is_published TINYINT(1) NOT NULL DEFAULT 0, -- Boolean field (0 or 1) created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_status_id (status_id) + INDEX idx_status_id (status_id), + INDEX idx_priority_id (priority_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "); }