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
");
}