Enhance refactor commands with controller-aware Route() updates and fix code quality violations

Add semantic token highlighting for 'that' variable and comment file references in VS Code extension
Add Phone_Text_Input and Currency_Input components with formatting utilities
Implement client widgets, form standardization, and soft delete functionality
Add modal scroll lock and update documentation
Implement comprehensive modal system with form integration and validation
Fix modal component instantiation using jQuery plugin API
Implement modal system with responsive sizing, queuing, and validation support
Implement form submission with validation, error handling, and loading states
Implement country/state selectors with dynamic data loading and Bootstrap styling
Revert Rsx::Route() highlighting in Blade/PHP files
Target specific PHP scopes for Rsx::Route() highlighting in Blade
Expand injection selector for Rsx::Route() highlighting
Add custom syntax highlighting for Rsx::Route() and Rsx.Route() calls
Update jqhtml packages to v2.2.165
Add bundle path validation for common mistakes (development mode only)
Create Ajax_Select_Input widget and Rsx_Reference_Data controller
Create Country_Select_Input widget with default country support
Initialize Tom Select on Select_Input widgets
Add Tom Select bundle for enhanced select dropdowns
Implement ISO 3166 geographic data system for country/region selection
Implement widget-based form system with disabled state support

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-30 06:21:56 +00:00
parent e678b987c2
commit f6ac36c632
5683 changed files with 5854736 additions and 22329 deletions

View File

@@ -52,3 +52,5 @@ GATEKEEPER_TITLE="Development Preview"
GATEKEEPER_SUBTITLE="This is a restricted development preview site. Please enter the access password to continue."
SSR_FPC_ENABLED=true
LOG_BROWSER_ERRORS=false
AJAX_DISABLE_BATCHING=true

View File

@@ -0,0 +1,47 @@
<?php
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Tom Select CDN Bundle
*
* Provides Tom Select (modern select2 alternative) via CDN.
* Tom Select is a lightweight, vanilla JS library for enhanced select boxes
* with search, ajax, tagging, and accessibility features.
*
* Features:
* - Text search/filtering
* - Ajax/remote data loading
* - Custom value creation (tagging)
* - Multi-select support
* - Excellent accessibility (ARIA, screen readers)
* - Bootstrap-friendly theming
*/
class Tom_Select_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [], // No local files
'cdn_assets' => [
'css' => [
[
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.default.min.css',
],
],
'js' => [
[
'url' => 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js',
],
],
],
];
}
}

View File

@@ -233,18 +233,18 @@ class HardcodedInternalUrl_CodeQualityRule extends CodeQualityRule_Abstract
if ($is_jqhtml) {
// JavaScript version for .jqhtml files using <%= %> syntax
if (empty($params)) {
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url() %>";
return "<%= Rsx.Route('{$class_name}', '{$method_name}') %>";
} else {
$params_json = json_encode($params, JSON_UNESCAPED_SLASHES);
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url({$params_json}) %>";
return "<%= Rsx.Route('{$class_name}', '{$method_name}', {$params_json}) %>";
}
} else {
// PHP version for .blade.php files
if (empty($params)) {
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url() }}";
return "{{ Rsx::Route('{$class_name}', '{$method_name}') }}";
} else {
$params_str = $this->_format_php_array($params);
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url({$params_str}) }}";
return "{{ Rsx::Route('{$class_name}', '{$method_name}', {$params_str}) }}";
}
}
}

View File

@@ -107,6 +107,10 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
return;
}
// Get original file content for extracting actual controller/method names
// (The $contents parameter may be sanitized with strings replaced by spaces)
$original_contents = file_get_contents($file_path);
// Pattern to match Rsx::Route and Rsx.Route calls (NOT plain Route())
// Matches both single and double parameter versions:
// - Rsx::Route('Controller') // PHP, defaults to 'index'
@@ -122,11 +126,15 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
// First check two-parameter calls
if (preg_match_all($pattern_two_params, $contents, $matches, PREG_OFFSET_CAPTURE)) {
// Also match against original content to get real controller/method names
preg_match_all($pattern_two_params, $original_contents, $original_matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
$controller = $matches[1][$index][0];
$method = $matches[2][$index][0];
// Extract controller and method from ORIGINAL content, not sanitized
$controller = $original_matches[1][$index][0] ?? $matches[1][$index][0];
$method = $original_matches[2][$index][0] ?? $matches[2][$index][0];
// Skip if contains template variables like {$variable}
if (str_contains($controller, '{$') || str_contains($controller, '${') ||
@@ -147,9 +155,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Extract the line for snippet - use ORIGINAL file content, not sanitized
$original_lines = explode("\n", $original_contents);
$code_snippet = isset($original_lines[$line_number - 1]) ? trim($original_lines[$line_number - 1]) : $full_match;
// Build suggestion
$suggestion = $this->build_suggestion($controller, $method);
@@ -167,6 +175,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
// Then check single-parameter calls (avoiding overlap with two-parameter calls)
if (preg_match_all($pattern_one_param, $contents, $matches, PREG_OFFSET_CAPTURE)) {
// Also match against original content to get real controller names
preg_match_all($pattern_one_param, $original_contents, $original_matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
@@ -178,7 +189,8 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
continue; // This is a two-parameter call, already handled above
}
$controller = $matches[1][$index][0];
// Extract controller from ORIGINAL content, not sanitized
$controller = $original_matches[1][$index][0] ?? $matches[1][$index][0];
$method = 'index'; // Default to 'index'
// Skip if contains template variables like {$variable}
@@ -194,9 +206,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Extract the line for snippet - use ORIGINAL file content, not sanitized
$original_lines = explode("\n", $original_contents);
$code_snippet = isset($original_lines[$line_number - 1]) ? trim($original_lines[$line_number - 1]) : $full_match;
// Build suggestion
$suggestion = $this->build_suggestion($controller, $method);

View File

@@ -119,18 +119,40 @@ class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
}
}
// Pattern 4: Guard clauses like: Rsx && Rsx.method()
if (preg_match('/(\w+)\s*&&\s*\1\.\w+/i', $line, $matches)) {
// Pattern 4: Guard clauses like: Rsx && Rsx.method() or component && component.val
if (preg_match('/\b(\w+)\s*&&\s*\1\.\w+/i', $line, $matches)) {
$variable = $matches[1];
// Skip common boolean variable patterns
if (!preg_match('/^(is|has|can|should|will|did|was)[A-Z]/', $variable)) {
$fix_message = "DETECTED PATTERN: if ({$variable} && {$variable}.method)\n\n" .
"This pattern has TWO possible meanings:\n" .
"1. DEFENSIVE CODING (violation): Checking for something that MUST exist\n" .
"2. DUCK TYPING (acceptable): Checking for optional capabilities\n\n" .
"FOR LLM AGENTS - YOU MUST NOT DECIDE:\n" .
"This rule requires human judgment. You MUST:\n" .
"1. Analyze the context and determine which scenario applies\n" .
"2. Present BOTH options:\n" .
" - If defensive: Remove check, let it fail loud\n" .
" - If duck typing: Use 'in' operator: if ('method' in {$variable})\n" .
"3. Provide your recommendation with reasoning\n" .
"4. WAIT for user decision\n" .
"5. NEVER apply a fix without user confirmation\n\n" .
"EXAMPLES:\n\n" .
"DEFENSIVE CODING (remove the check):\n" .
" if (Rsx && Rsx.Route(...)) // BAD - Rsx is core, must exist\n" .
" if (component && component.render()) // BAD if render is required method\n\n" .
"DUCK TYPING (use 'in' operator):\n" .
" if (component && component.val) // Change to: if ('val' in component)\n" .
" if (obj && obj.serialize) // Change to: if ('serialize' in obj)\n\n" .
"NOTE: Core guaranteed classes (Rsx, Modal, Jqhtml_Component, etc.) should NEVER be checked - let failures happen loudly during development.";
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Guard clause checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the guard clause. Use '{$variable}.method()' directly.",
$fix_message,
'high'
);
}

View File

@@ -271,10 +271,10 @@ class HardcodedUrlInRedirect_CodeQualityRule extends CodeQualityRule_Abstract
protected function _generate_rsx_suggestion(string $class_name, string $method_name, array $params): string
{
if (empty($params)) {
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}')->url());";
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}'));";
} else {
$params_str = $this->_format_php_array($params);
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}')->url({$params_str}));";
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}', {$params_str}));";
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace App\RSpade\Commands\Database;
use Illuminate\Console\Command;
use Sokil\IsoCodes\IsoCodesFactory;
use App\RSpade\Core\Models\Country_Model;
use App\RSpade\Core\Models\Region_Model;
/**
* Seed geographic data (countries and regions) from ISO 3166 standards
*
* Uses sokil/php-isocodes library to populate countries (ISO 3166-1)
* and regions/subdivisions (ISO 3166-2) tables.
*/
#[Instantiatable]
class Seed_Geographic_Data_Command extends Command
{
protected $signature = 'rsx:seed:geographic-data';
protected $description = 'Import countries and regions from ISO 3166 standards';
public function handle()
{
$this->info('Importing geographic data from ISO 3166 standards...');
$this->newLine();
// Initialize ISO codes library
$isoCodes = new IsoCodesFactory();
// Seed countries
$this->seedCountries($isoCodes);
// Seed regions
$this->seedRegions($isoCodes);
$this->newLine();
$this->info('Geographic data import completed successfully!');
return Command::SUCCESS;
}
private function seedCountries(IsoCodesFactory $isoCodes)
{
$this->info('[Countries] Loading ISO 3166-1 data...');
$countries = $isoCodes->getCountries();
$imported_alpha2_codes = [];
$added = 0;
$updated = 0;
foreach ($countries as $country) {
$alpha2 = $country->getAlpha2();
$imported_alpha2_codes[] = $alpha2;
$existing = Country_Model::where('alpha2', $alpha2)->first();
if ($existing) {
$existing->alpha2 = $alpha2;
$existing->alpha3 = $country->getAlpha3();
$existing->numeric = $country->getNumericCode();
$existing->name = $country->getName();
$existing->common_name = $country->getLocalName() !== $country->getName()
? $country->getLocalName()
: null;
$existing->enabled = true;
$existing->save();
$updated++;
} else {
$model = new Country_Model();
$model->alpha2 = $alpha2;
$model->alpha3 = $country->getAlpha3();
$model->numeric = $country->getNumericCode();
$model->name = $country->getName();
$model->common_name = $country->getLocalName() !== $country->getName()
? $country->getLocalName()
: null;
$model->enabled = true;
$model->save();
$added++;
}
}
// Disable countries not in import
$disabled = Country_Model::whereNotIn('alpha2', $imported_alpha2_codes)
->where('enabled', true)
->update(['enabled' => false]);
$this->line("[Countries] Added: {$added}, Updated: {$updated}, Disabled: {$disabled}");
}
private function seedRegions(IsoCodesFactory $isoCodes)
{
$this->info('[Regions] Loading ISO 3166-2 subdivisions...');
$subdivisions = $isoCodes->getSubdivisions();
$imported_keys = [];
$added = 0;
$updated = 0;
foreach ($subdivisions as $subdivision) {
$code = $subdivision->getCode();
// Extract country code from subdivision code (e.g., "US-CA" -> "US")
$parts = explode('-', $code);
if (count($parts) < 2) {
// Skip malformed codes
continue;
}
$country_alpha2 = $parts[0];
// Only import if country exists
if (!Country_Model::where('alpha2', $country_alpha2)->exists()) {
continue;
}
$imported_keys[] = $country_alpha2 . '|' . $code;
$existing = Region_Model::where('country_alpha2', $country_alpha2)
->where('code', $code)
->first();
if ($existing) {
$existing->code = $code;
$existing->country_alpha2 = $country_alpha2;
$existing->name = $subdivision->getName();
$existing->type = $subdivision->getType();
$existing->enabled = true;
$existing->save();
$updated++;
} else {
$model = new Region_Model();
$model->code = $code;
$model->country_alpha2 = $country_alpha2;
$model->name = $subdivision->getName();
$model->type = $subdivision->getType();
$model->enabled = true;
$model->save();
$added++;
}
}
// Disable regions not in import
$disabled = 0;
$all_regions = Region_Model::where('enabled', true)->get();
foreach ($all_regions as $region) {
$key = $region->country_alpha2 . '|' . $region->code;
if (!in_array($key, $imported_keys)) {
$region->enabled = false;
$region->save();
$disabled++;
}
}
$this->line("[Regions] Added: {$added}, Updated: {$updated}, Disabled: {$disabled}");
}
}

View File

@@ -337,4 +337,93 @@ class FileUpdater
return $updated;
}
/**
* Update controller references in Route() calls and Ajax endpoints
* Only processes files in rsx/ directory that are in the manifest
*
* @param string $old_class Old controller class name
* @param string $new_class New controller class name
* @return int Number of files updated
*/
public function update_controller_route_references(string $old_class, string $new_class): int
{
$updated_count = 0;
// Get all files from manifest in rsx/ directory
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
foreach ($manifest as $relative_path => $metadata) {
// Only process files in rsx/ directory
if (!str_starts_with($relative_path, 'rsx/')) {
continue;
}
$extension = $metadata['extension'] ?? '';
$file_path = base_path($relative_path);
if (!file_exists($file_path)) {
continue;
}
$content = file_get_contents($file_path);
$updated_content = $content;
// Apply replacements based on file type
if ($extension === 'js' || $extension === 'jqhtml') {
// Replace Rsx.Route('OLD_CLASS' and Rsx.Route("OLD_CLASS"
$updated_content = preg_replace(
'/\bRsx\.Route\([\'"]' . preg_quote($old_class, '/') . '[\'"]/',
'Rsx.Route(\'' . $new_class . '\'',
$updated_content
);
}
if ($extension === 'jqhtml' || $extension === 'blade.php') {
// Replace $controller="OLD_CLASS"
$updated_content = preg_replace(
'/(\s\$controller=["\'])' . preg_quote($old_class, '/') . '(["\'])/',
'$1' . $new_class . '$2',
$updated_content
);
}
if ($extension === 'jqhtml') {
// Replace unquoted attribute assignments: $attr=OLD_CLASS followed by space, dot, or >
// Pattern: (attribute name)=(controller name)(space|dot|>)
$updated_content = preg_replace(
'/(\$[\w_]+)=' . preg_quote($old_class, '/') . '\b(?=[\s.>])/',
'$1=' . $new_class,
$updated_content
);
}
if ($extension === 'js') {
// Replace Ajax endpoint calls: OLD_CLASS.method(
// Pattern: (whitespace|;|(|[)OLD_CLASS.
$updated_content = preg_replace(
'/(?<=[\s;(\[])' . preg_quote($old_class, '/') . '\b(?=\.)/',
$new_class,
$updated_content
);
}
if ($extension === 'php' || $extension === 'blade.php') {
// Replace Rsx::Route('OLD_CLASS' and Rsx::Route("OLD_CLASS"
$updated_content = preg_replace(
'/\bRsx::Route\([\'"]' . preg_quote($old_class, '/') . '[\'"]/',
'Rsx::Route(\'' . $new_class . '\'',
$updated_content
);
}
// Write if changed
if ($updated_content !== $content) {
file_put_contents($file_path, $updated_content);
$updated_count++;
}
}
return $updated_count;
}
}

View File

@@ -340,6 +340,106 @@ class MethodUpdater
return $content;
}
/**
* Update controller action references in Route() calls and Ajax endpoints
* Only processes files in rsx/ directory that are in the manifest
*
* @param string $class_name Controller class name
* @param string $old_action Old action/method name
* @param string $new_action New action/method name
* @return int Number of files updated
*/
public function update_controller_action_route_references(string $class_name, string $old_action, string $new_action): int
{
$updated_count = 0;
// Get all files from manifest in rsx/ directory
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
foreach ($manifest as $relative_path => $metadata) {
// Only process files in rsx/ directory
if (!str_starts_with($relative_path, 'rsx/')) {
continue;
}
$extension = $metadata['extension'] ?? '';
$file_path = base_path($relative_path);
if (!file_exists($file_path)) {
continue;
}
$content = file_get_contents($file_path);
$updated_content = $content;
// Apply replacements based on file type
if ($extension === 'js' || $extension === 'jqhtml') {
// Replace Rsx.Route('CLASS', 'OLD_ACTION'
// Pattern matches with optional whitespace between parameters
$updated_content = preg_replace(
'/\bRsx\.Route\(\s*[\'"]' . preg_quote($class_name, '/') . '[\'"]\s*,\s*[\'"]' . preg_quote($old_action, '/') . '[\'"]/',
'Rsx.Route(\'' . $class_name . '\', \'' . $new_action . '\'',
$updated_content
);
}
if ($extension === 'jqhtml' || $extension === 'blade.php') {
// Replace $action="OLD_ACTION" when $controller="CLASS_NAME" is on same line
// This uses a callback to check both attributes are present
$updated_content = preg_replace_callback(
'/^.*$/m',
function ($matches) use ($class_name, $old_action, $new_action) {
$line = $matches[0];
// Check if line has both $controller="CLASS_NAME" and $action="OLD_ACTION"
$has_controller = preg_match('/\$controller=["\']' . preg_quote($class_name, '/') . '["\']/', $line);
$has_action = preg_match('/\$action=["\']' . preg_quote($old_action, '/') . '["\']/', $line);
if ($has_controller && $has_action) {
// Replace the action
return preg_replace(
'/(\$action=["\'])' . preg_quote($old_action, '/') . '(["\'])/',
'$1' . $new_action . '$2',
$line
);
}
return $line;
},
$updated_content
);
}
if ($extension === 'js') {
// Replace Ajax endpoint calls: CLASS.OLD_ACTION(
// Pattern: (non-alnum)(CLASS).(METHOD)(non-alnum)
$updated_content = preg_replace(
'/(?<=[^a-zA-Z0-9_])' . preg_quote($class_name, '/') . '\.' . preg_quote($old_action, '/') . '(?=[^a-zA-Z0-9_])/',
$class_name . '.' . $new_action,
$updated_content
);
}
if ($extension === 'php' || $extension === 'blade.php') {
// Replace Rsx::Route('CLASS', 'OLD_ACTION'
// Pattern matches with optional whitespace between parameters
$updated_content = preg_replace(
'/\bRsx::Route\(\s*[\'"]' . preg_quote($class_name, '/') . '[\'"]\s*,\s*[\'"]' . preg_quote($old_action, '/') . '[\'"]/',
'Rsx::Route(\'' . $class_name . '\', \'' . $new_action . '\'',
$updated_content
);
}
// Write if changed
if ($updated_content !== $content) {
file_put_contents($file_path, $updated_content);
$updated_count++;
}
}
return $updated_count;
}
/**
* Write file atomically using temp file
*/

View File

@@ -115,6 +115,18 @@ class RefactorPhpClass_Command extends Command
return 0;
}
// Step 6.5: Check if this is a controller (subclass of Rsx_Controller)
$is_controller = false;
try {
if (Manifest::php_is_subclass_of($old_class, 'Rsx_Controller_Abstract')) {
$is_controller = true;
$this->info("");
$this->info("<fg=cyan>Detected controller class - will also update Route() references</>");
}
} catch (\Exception $e) {
// Class might not be loaded, skip controller check
}
// Step 7: Apply changes
$this->info("");
$this->info("Applying changes...");
@@ -125,6 +137,20 @@ class RefactorPhpClass_Command extends Command
$this->info("");
$this->info("<fg=green>Successfully updated {$updated_count} file(s)</>");
// Step 7.5: If controller, do additional Route() replacements
if ($is_controller) {
$this->info("");
$this->info("Updating Route() references in rsx/ files...");
$route_updated_count = $updater->update_controller_route_references($old_class, $new_class);
if ($route_updated_count > 0) {
$this->info("<fg=green>✓</> Updated Route() references in {$route_updated_count} file(s)");
} else {
$this->line("<fg=gray>No Route() references found</>");
}
}
// Step 8: Log refactor operation for upstream tracking
RefactorLog::log_refactor(
"rsx:refactor:rename_php_class {$old_class} {$new_class}",
@@ -138,6 +164,22 @@ class RefactorPhpClass_Command extends Command
$this->rename_source_file_if_needed($source_path, $new_class, $source_metadata);
}
// Step 10: Final grep for any remaining references
$this->info("");
$this->info("Checking for remaining references to '{$old_class}'...");
$remaining_files = $this->grep_remaining_references($old_class);
if (!empty($remaining_files)) {
$this->warn("");
$this->warn("Found {old_class} in " . count($remaining_files) . " file(s) (manual review needed):");
foreach ($remaining_files as $file) {
$this->warn(" - {$file}");
}
} else {
$this->info("<fg=green>✓</> No remaining references found");
}
$this->info("");
$this->info("Next steps:");
$this->info(" 1. Review changes: git diff");
@@ -192,4 +234,49 @@ class RefactorPhpClass_Command extends Command
rename($old_absolute, $new_absolute);
$this->info("<fg=green>✓</> Renamed: {$current_filename}{$suggested_filename}");
}
/**
* Grep for remaining references to the old class name in ./rsx
*
* @param string $old_class Old class name to search for
* @return array List of relative file paths where the class name still appears
*/
protected function grep_remaining_references(string $old_class): array
{
$rsx_path = base_path('rsx');
if (!is_dir($rsx_path)) {
return [];
}
// Use grep to find files containing the old class name
// -r = recursive
// -l = list files only
// --include = only search these file types
// -w = match whole word
$command = sprintf(
'grep -r -l -w %s --include="*.php" --include="*.blade.php" --include="*.js" --include="*.jqhtml" %s 2>/dev/null',
escapeshellarg($old_class),
escapeshellarg($rsx_path)
);
$output = shell_exec($command);
if (empty($output)) {
return [];
}
$files = explode("\n", trim($output));
// Convert to relative paths
$relative_files = [];
foreach ($files as $file) {
if (empty($file)) {
continue;
}
$relative_files[] = str_replace(base_path() . '/', '', $file);
}
return $relative_files;
}
}

View File

@@ -146,6 +146,32 @@ class RenamePhpClassFunction_Command extends Command
}
}
// Phase 3.5: If controller, update Route() references
if ($method_was_renamed) {
$is_controller = false;
try {
if (Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
$is_controller = true;
}
} catch (\Exception $e) {
// Class might not be loaded, skip controller check
}
if ($is_controller) {
$this->info("");
$this->info("Detected controller class - updating Route() action references...");
$updater = $updater ?? new MethodUpdater();
$route_updated_count = $updater->update_controller_action_route_references($class_name, $old_method, $new_method);
if ($route_updated_count > 0) {
$this->info("<fg=green>✓</> Updated Route() references in {$route_updated_count} file(s)");
} else {
$this->line("<fg=gray>No Route() action references found</>");
}
}
}
// Phase 4: Recursive subclass renaming (unless --skip-subclasses)
if (!$skip_subclasses) {
$this->info("");
@@ -182,6 +208,22 @@ class RenamePhpClassFunction_Command extends Command
$class_path
);
// Phase 5: Final grep for any remaining references
$this->info("");
$this->info("Checking for remaining references to '{$class_name}' and '{$old_method}' on same line...");
$remaining_files = $this->grep_remaining_method_references($class_name, $old_method);
if (!empty($remaining_files)) {
$this->warn("");
$this->warn("Found {$class_name} and {$old_method} on same line in " . count($remaining_files) . " file(s) (manual review needed):");
foreach ($remaining_files as $file) {
$this->warn(" - {$file}");
}
} else {
$this->info("<fg=green>✓</> No remaining references found");
}
// Success summary
$this->info("");
$this->info("<fg=green>✓</> Successfully renamed {$class_name}::{$old_method}{$class_name}::{$new_method}");
@@ -193,6 +235,61 @@ class RenamePhpClassFunction_Command extends Command
return 0;
}
/**
* Grep for files where both class name and old method name appear on the same line
*
* @param string $class_name Class name to search for
* @param string $old_method Old method name to search for
* @return array List of relative file paths where both appear on same line
*/
protected function grep_remaining_method_references(string $class_name, string $old_method): array
{
$rsx_path = base_path('rsx');
if (!is_dir($rsx_path)) {
return [];
}
// Use grep to find files containing both class name and method name on same line
// We'll grep for the class name, then check each result for method name on same line
$command = sprintf(
'grep -r -l -w %s --include="*.php" --include="*.blade.php" --include="*.js" --include="*.jqhtml" %s 2>/dev/null',
escapeshellarg($class_name),
escapeshellarg($rsx_path)
);
$output = shell_exec($command);
if (empty($output)) {
return [];
}
$candidate_files = explode("\n", trim($output));
$matching_files = [];
// For each file that contains the class name, check if any line has both class and method
foreach ($candidate_files as $file) {
if (empty($file)) {
continue;
}
// Read file and check each line
$content = file_get_contents($file);
$lines = explode("\n", $content);
foreach ($lines as $line) {
// Check if line contains both class name and method name as whole words
if (preg_match('/\b' . preg_quote($class_name, '/') . '\b/', $line) &&
preg_match('/\b' . preg_quote($old_method, '/') . '\b/', $line)) {
$matching_files[] = str_replace(base_path() . '/', '', $file);
break; // Found in this file, no need to check more lines
}
}
}
return array_unique($matching_files);
}
/**
* Find a method in a class file and return metadata
*

View File

@@ -13,53 +13,43 @@ use App\RSpade\Core\Ajax\Exceptions\AjaxAuthRequiredException;
use App\RSpade\Core\Ajax\Exceptions\AjaxUnauthorizedException;
use App\RSpade\Core\Ajax\Exceptions\AjaxFormErrorException;
use App\RSpade\Core\Ajax\Exceptions\AjaxFatalErrorException;
use App\RSpade\Core\Session\RsxSession;
use App\RSpade\Core\Session\Session;
use App\RSpade\Core\Debug\Debugger;
/**
* RSX Ajax Debug Command
* =======================
* RSX Ajax Command
* =================
*
* PURPOSE:
* This command allows testing Internal API (Ajax) calls from the command line.
* It sets up session context (user_id, site_id) and executes Ajax::call() with
* the specified controller, action, and parameters.
* Execute Ajax endpoint methods from CLI with JSON output.
* Designed for testing, automation, and scripting.
*
* HOW IT WORKS:
* 1. Rotates logs for clean slate debugging
* 2. Sets user_id and/or site_id in RsxSession if provided
* 3. Calls Ajax::call() with the specified controller and action
* 4. Outputs pretty-printed JSON response
* 5. Handles special response types (AuthRequired, FormError, etc.)
* 6. Rotates logs again after test for clean slate on next run
*
* KEY FEATURES:
* - User context: Use --user-id to test as any user
* - Site context: Use --site-id to test with specific site
* - JSON parameters: Pass complex data structures as JSON
* - Pretty output: Responses are formatted for readability
* - Form error handling: Special formatting for validation errors
* - Log integration: Automatic log rotation for clean testing
* OUTPUT MODES:
* 1. Default: Raw JSON response (just the return value)
* 2. --debug: Full HTTP-like response with {success, _ajax_return_value, console_debug}
* 3. --verbose: Add request context before JSON output
*
* USAGE EXAMPLES:
* php artisan rsx:ajax:debug User_Api get_profile # Basic call
* php artisan rsx:ajax:debug User_Api update --args='{"name":"John"}' # With params
* php artisan rsx:ajax:debug Admin_Api list_users --user-id=1 # As user 1
* php artisan rsx:ajax:debug Site_Api get_stats --site-id=5 # With site 5
* php artisan rsx:ajax:debug Auth_Api login --args='{"email":"test@example.com","password":"secret"}'
* php artisan rsx:ajax Controller action # Basic call
* php artisan rsx:ajax Controller action --args='{"id":1}' # With params
* php artisan rsx:ajax Controller action --site-id=1 # With site context
* php artisan rsx:ajax Controller action --user-id=1 --site-id=1 # Full context
* php artisan rsx:ajax Controller action --debug # HTTP-like response
* php artisan rsx:ajax Controller action --verbose --site-id=1 # Show context
*
* OUTPUT FORMAT:
* - Success: Pretty-printed JSON response
* - Auth Required: Shows authentication error with login URL
* - Unauthorized: Shows authorization error message
* - Form Error: Shows validation errors with field details
* - Fatal Error: Shows error message and stack trace
* - Invalid controller/action: Clear error message
* Default: {"records":[...], "total":10}
* --debug: {"success":true, "_ajax_return_value":{...}, "console_debug":[...]}
* --verbose: Adds "Set site_id to 1" before JSON
*
* SECURITY:
* - Only available in local/development/testing environments
* - Throws fatal error if attempted in production
* - Bypasses normal HTTP authentication for testing
* ERROR HANDLING:
* All errors return JSON (never throws to stderr)
* {"success":false, "error":"Error message", "error_type":"exception_type"}
*
* USE CASES:
* - Test Ajax endpoints behind auth/site scoping
* - Invoke RPC calls from automation scripts
* - Debug API responses in production environments
*/
class Ajax_Debug_Command extends Command
{
@@ -68,29 +58,27 @@ class Ajax_Debug_Command extends Command
*
* @var string
*/
protected $signature = 'rsx:ajax:debug
{controller : The RSX controller name (e.g., User_Api, Admin_Api)}
{action : The action/method name (e.g., get_profile, update)}
protected $signature = 'rsx:ajax
{controller : The RSX controller name}
{action : The action/method name}
{--args= : JSON-encoded arguments to pass to the action}
{--user-id= : Set user ID for session context}
{--site-id= : Set site ID for session context}';
{--site-id= : Set site ID for session context}
{--debug : Wrap output in HTTP-like response format (success, _ajax_return_value, console_debug)}
{--show-context : Show request context before JSON output}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Debug Internal API (Ajax) calls from command line - executes Ajax::call() with session context (development only)';
protected $description = 'Execute Ajax endpoints from CLI with JSON output';
/**
* Execute the console command.
*/
public function handle()
{
// Check environment - throw fatal error in production
if (app()->environment('production')) {
throw new \RuntimeException('FATAL: rsx:ajax:debug command is not available in production environment. This is a development-only debugging tool.');
}
// Get command arguments
$controller = $this->argument('controller');
@@ -102,123 +90,119 @@ class Ajax_Debug_Command extends Command
$args_json = $this->option('args');
$args = json_decode($args_json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error('Invalid JSON in --args parameter: ' . json_last_error_msg());
$this->output_json_error('Invalid JSON in --args parameter: ' . json_last_error_msg(), 'invalid_json');
return 1;
}
}
// Get user and site IDs
// Get options
$user_id = $this->option('user-id');
$site_id = $this->option('site-id');
$debug_mode = $this->option('debug');
$show_context = $this->option('show-context');
// Rotate logs before test to ensure clean slate
// Rotate logs before test
Debugger::logrotate();
// Set session context if provided
if ($user_id !== null) {
RsxSession::set_user_id((int)$user_id);
$this->info("✓ Set user_id to {$user_id}");
Session::set_user_id((int)$user_id);
if ($show_context) {
$this->error("Set user_id to {$user_id}");
}
}
if ($site_id !== null) {
RsxSession::set_site_id((int)$site_id);
$this->info("✓ Set site_id to {$site_id}");
Session::set_site_id((int)$site_id);
if ($show_context) {
$this->error("Set site_id to {$site_id}");
}
}
// Show what we're calling
$this->info("");
$this->info("Calling: {$controller}::{$action}");
if (!empty($args)) {
$this->info("Arguments: " . json_encode($args, JSON_PRETTY_PRINT));
}
$this->info("");
$this->line("─────────────────────────────────────────");
$this->info("");
try {
// Call the Ajax method
$response = Ajax::internal($controller, $action, $args);
// Output successful response
$this->info("✅ Success Response:");
$this->info("");
// Pretty print the JSON response
$json_output = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->line($json_output);
// Output response based on mode
if ($debug_mode) {
// Use shared format_ajax_response method for consistency with HTTP
$wrapped_response = Ajax::format_ajax_response($response);
$this->output_json($wrapped_response);
} else {
// Just output the raw response
$this->output_json($response);
}
} catch (AjaxAuthRequiredException $e) {
// Handle auth required response
$this->error("🔒 Authentication Required");
$this->error("");
$this->error("Login URL: " . $e->getMessage());
$this->output_json_error($e->getMessage(), 'auth_required');
return 1;
} catch (AjaxUnauthorizedException $e) {
// Handle unauthorized response
$this->error("⛔ Unauthorized");
$this->error("");
$this->error("Message: " . $e->getMessage());
$this->output_json_error($e->getMessage(), 'unauthorized');
return 1;
} catch (AjaxFormErrorException $e) {
// Handle form error response - format like /_ajax route
$this->error("❌ Form Validation Error");
$this->error("");
$details = $e->get_details();
// Format the response like the /_ajax route does
$formatted_response = [
$error_response = [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
'error_type' => 'form_error',
];
// Add any additional details from the exception
$details = $e->get_details();
if (!empty($details)) {
foreach ($details as $key => $value) {
if ($key !== 'message') {
$formatted_response[$key] = $value;
}
}
$error_response['details'] = $details;
}
$json_output = json_encode($formatted_response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->line($json_output);
$this->output_json($error_response);
return 1;
} catch (AjaxFatalErrorException $e) {
// Handle fatal error response
$this->error("💀 Fatal Error");
$this->error("");
$this->error("Message: " . $e->getMessage());
$this->error("");
$this->error("Stack Trace:");
$this->line($e->getTraceAsString());
$this->output_json_error($e->getMessage(), 'fatal_error', [
'trace' => $e->getTraceAsString()
]);
return 1;
} catch (\InvalidArgumentException $e) {
// Handle invalid controller/action
$this->error("❌ Invalid Request");
$this->error("");
$this->error($e->getMessage());
$this->output_json_error($e->getMessage(), 'invalid_argument');
return 1;
} catch (\Exception $e) {
// Handle unexpected exceptions
$this->error("💥 Unexpected Error");
$this->error("");
$this->error("Exception: " . get_class($e));
$this->error("Message: " . $e->getMessage());
$this->error("");
$this->error("Stack Trace:");
$this->line($e->getTraceAsString());
$this->output_json_error($e->getMessage(), get_class($e), [
'trace' => $e->getTraceAsString()
]);
return 1;
}
// Rotate logs after test to clean slate for next run
// Rotate logs after test
Debugger::logrotate();
return 0;
}
/**
* Output JSON to stdout (pretty-printed)
*/
protected function output_json($data): void
{
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->line($json);
}
/**
* Output error as JSON
*/
protected function output_json_error(string $message, string $error_type, array $extra = []): void
{
$error = [
'success' => false,
'error' => $message,
'error_type' => $error_type,
];
foreach ($extra as $key => $value) {
$error[$key] = $value;
}
$this->output_json($error);
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use Illuminate\Console\Command;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
/**
* RSX Task List Command
* ===================
*
* Lists all available tasks organized by service
*/
class Task_List_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:task:list';
/**
* The console command description.
*
* @var string
*/
protected $description = 'List all available tasks';
/**
* Execute the console command.
*/
public function handle()
{
$manifest = Manifest::get_all();
$services = [];
// Find all services and their tasks
foreach ($manifest as $file_path => $info) {
// Skip non-PHP files or files without classes
if (!isset($info['class']) || !isset($info['fqcn'])) {
continue;
}
// Check if class extends Rsx_Service_Abstract
if (!isset($info['extends']) || $info['extends'] !== 'Rsx_Service_Abstract') {
continue;
}
$service_name = $info['class'];
$service_tasks = [];
// Find methods with Task attribute
if (isset($info['public_static_methods'])) {
foreach ($info['public_static_methods'] as $method_name => $method_info) {
if (!isset($method_info['attributes'])) {
continue;
}
// Look for Task attribute
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
// Extract description from first attribute instance
$description = '';
if (!empty($attr_instances) && isset($attr_instances[0]['arguments'][0])) {
$description = $attr_instances[0]['arguments'][0];
}
$service_tasks[] = [
'name' => $method_name,
'description' => $description
];
}
}
}
}
if (!empty($service_tasks)) {
$services[$service_name] = $service_tasks;
}
}
if (empty($services)) {
$this->info('No tasks found.');
return 0;
}
// Display tasks grouped by service
$this->info('Available Tasks:');
$this->newLine();
foreach ($services as $service_name => $tasks) {
$this->line(" <fg=cyan>{$service_name}</>");
foreach ($tasks as $task) {
$task_name = str_pad($task['name'], 25);
$description = $task['description'] ?: '(no description)';
$this->line(" <fg=green>{$task_name}</> - {$description}");
}
$this->newLine();
}
return 0;
}
}

View File

@@ -0,0 +1,191 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use Illuminate\Console\Command;
use App\RSpade\Core\Task\Task;
/**
* RSX Task Run Command
* ====================
*
* PURPOSE:
* Execute task methods from CLI with JSON output.
* Designed for background jobs, seeders, maintenance, and automation.
*
* OUTPUT MODES:
* 1. Default: Raw JSON response (just the return value)
* 2. --debug: Wrapped response with {success, result}
*
* USAGE EXAMPLES:
* php artisan rsx:task:run Service task_name # Basic execution
* php artisan rsx:task:run Service task_name --param=value # With parameters
* php artisan rsx:task:run Service task_name --flag # Boolean parameter
* php artisan rsx:task:run Service task_name --debug # Wrapped response
*
* PARAMETER HANDLING:
* All --key=value options become $params['key'] = 'value'
* Boolean flags: --flag becomes $params['flag'] = true
* JSON values: --data='{"foo":"bar"}' is parsed automatically
*
* ERROR HANDLING:
* All errors return JSON (never throws to stderr)
* {"success":false, "error":"Error message", "error_type":"exception_type"}
*
* USE CASES:
* - Run database seeders
* - Execute maintenance tasks
* - Generate reports
* - Process background jobs
*/
class Task_Run_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:task:run
{service : The RSX service name}
{task : The task/method name}
{--debug : Wrap output in formatted response (success, result)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Execute tasks from CLI with JSON output';
/**
* Get the console command arguments and options.
* Allow any options to be passed through to tasks.
*/
protected function getOptions()
{
// Parse parent options first
$options = parent::getOptions();
// Allow any additional options by not validating them
return $options;
}
/**
* Configure the command to accept any options
*/
protected function configure()
{
parent::configure();
// Tell Symfony to ignore unknown options
$this->ignoreValidationErrors();
}
/**
* Execute the console command.
*/
public function handle()
{
// Get command arguments
$service = $this->argument('service');
$task = $this->argument('task');
$debug_mode = $this->option('debug');
// Parse all remaining options as task parameters
// We need to manually parse from $_SERVER['argv'] because Laravel validates options
$params = [];
$skip_builtins = ['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'env', 'debug'];
// Parse from raw argv
$argv = $_SERVER['argv'] ?? [];
for ($i = 0; $i < count($argv); $i++) {
$arg = $argv[$i];
// Check if this is an option
if (!str_starts_with($arg, '--')) {
continue;
}
// Remove -- prefix
$option_string = substr($arg, 2);
// Parse key=value or just key
if (str_contains($option_string, '=')) {
[$key, $value] = explode('=', $option_string, 2);
} else {
$key = $option_string;
$value = true; // Boolean flag
}
// Skip built-in Laravel options
if (in_array($key, $skip_builtins)) {
continue;
}
// Try to parse JSON values
if (is_string($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
// Store parameter
$params[$key] = $value;
}
try {
// Execute the task
$response = Task::internal($service, $task, $params);
// Output response based on mode
if ($debug_mode) {
$wrapped_response = Task::format_task_response($response);
$this->output_json($wrapped_response);
} else {
// Just output the raw response
$this->output_json($response);
}
} catch (\Exception $e) {
$this->output_json_error($e->getMessage(), get_class($e), [
'trace' => $e->getTraceAsString()
]);
return 1;
}
return 0;
}
/**
* Output JSON to stdout (pretty-printed)
*/
protected function output_json($data): void
{
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->line($json);
}
/**
* Output error as JSON
*/
protected function output_json_error(string $message, string $error_type, array $extra = []): void
{
$error = [
'success' => false,
'error' => $message,
'error_type' => $error_type,
];
foreach ($extra as $key => $value) {
$error[$key] = $value;
}
$this->output_json($error);
}
}

View File

@@ -262,16 +262,34 @@ class Ajax
throw new Exception("Method {$action_name} in {$controller_class} must have Ajax_Endpoint annotation");
}
// Extract params from request body
$ajax_params = [];
// First check for JSON-encoded params field (old format)
$params_json = $request->input('params');
if ($params_json) {
$ajax_params = json_decode($params_json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON in params: ' . json_last_error_msg());
}
} else {
// Get all request input (form-urlencoded or JSON body)
$all_input = $request->all();
// Remove route parameters (controller, action, etc)
$ajax_params = array_diff_key($all_input, array_flip(['controller', 'action', '_method', '_route', '_handler']));
}
// Call pre_dispatch if it exists
$response = null;
if (method_exists($controller_class, 'pre_dispatch')) {
$response = $controller_class::pre_dispatch($request, $params);
$response = $controller_class::pre_dispatch($request, $ajax_params);
}
// If pre_dispatch returned something, use that as response
if ($response === null) {
// Call the actual method
$response = $controller_class::$action_name($request, $params);
$response = $controller_class::$action_name($request, $ajax_params);
}
// Handle special response types
@@ -279,18 +297,8 @@ class Ajax
return static::_handle_browser_special_response($response);
}
// Wrap normal response in success JSON
$json_response = [
'success' => true,
'_ajax_return_value' => $response,
];
// Add console debug messages if any
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
if (!empty($console_messages)) {
// Messages are now structured as [channel, [arguments]]
$json_response['console_debug'] = $console_messages;
}
// Wrap response using shared formatting method
$json_response = static::format_ajax_response($response);
return response()->json($json_response);
}
@@ -361,6 +369,33 @@ class Ajax
return static::$ajax_response_mode;
}
/**
* Format Ajax response with standard wrapper structure
*
* This method creates the standardized response format used by both
* HTTP Ajax endpoints and CLI Ajax commands. Ensures consistency across
* all Ajax response types.
*
* @param mixed $response The Ajax method return value
* @return array Formatted response array with success, _ajax_return_value, and optional console_debug
*/
public static function format_ajax_response($response): array
{
$json_response = [
'success' => true,
'_ajax_return_value' => $response,
];
// Add console debug messages if any
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
if (!empty($console_messages)) {
// Messages are now structured as [channel, [arguments]]
$json_response['console_debug'] = $console_messages;
}
return $json_response;
}
/**
* Backward compatibility alias for internal()
* @deprecated Use internal() instead

View File

@@ -577,6 +577,27 @@ class BundleCompiler
);
}
// Validate path format (development mode only)
if (!app()->environment('production') && is_string($item)) {
if (str_starts_with($item, 'system/app/') || str_starts_with($item, '/system/app/')) {
throw new RuntimeException(
"'system/app/' is not a valid RSpade path. Use 'app/' instead.\n" .
"Invalid path: {$item}\n" .
"Correct path: " . str_replace('system/app/', 'app/', $item) . "\n\n" .
'From the bundle perspective, framework code is at app/, not system/app/'
);
}
if (str_starts_with($item, 'system/rsx/') || str_starts_with($item, '/system/rsx/')) {
throw new RuntimeException(
"'system/rsx/' is not a valid RSpade path. Use 'rsx/' instead.\n" .
"Invalid path: {$item}\n" .
"Correct path: " . str_replace('system/rsx/', 'rsx/', $item) . "\n\n" .
'From the bundle perspective, application code is at rsx/, not system/rsx/'
);
}
}
// Otherwise treat as file/directory path
$path = base_path($item);
@@ -1805,7 +1826,14 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
foreach ($files_to_concat as $file) {
// Use babel-transformed version if it exists, otherwise use original
$file_to_use = $this->babel_file_mapping[$file] ?? $file;
$cmd_parts[] = escapeshellarg($file_to_use);
// If this is a babel-transformed file, pass metadata to concat-js
// Format: babel_file_path::original_file_path
if (isset($this->babel_file_mapping[$file])) {
$cmd_parts[] = escapeshellarg($file_to_use . '::' . $file);
} else {
$cmd_parts[] = escapeshellarg($file_to_use);
}
}
$cmd = implode(' ', $cmd_parts);

View File

@@ -23,6 +23,7 @@ class Core_Bundle extends Rsx_Bundle_Abstract
'include' => [
__DIR__,
'app/RSpade/Core/Js',
'app/RSpade/Core/Data',
],
];
}

View File

@@ -82,19 +82,37 @@ async function concatenateFiles() {
// Process each input file
for (const inputFile of inputFiles) {
if (!fs.existsSync(inputFile)) {
console.error(`Error: Input file not found: ${inputFile}`);
// Check if this is babel-transformed file with metadata
// Format: babel_file_path::original_file_path
let actualFile = inputFile;
let displayPath = null;
let isBabel = false;
if (inputFile.includes('::')) {
const parts = inputFile.split('::');
actualFile = parts[0]; // The babel-transformed file to read
displayPath = parts[1]; // The original source file for display
isBabel = true;
}
if (!fs.existsSync(actualFile)) {
console.error(`Error: Input file not found: ${actualFile}`);
process.exit(1);
}
// Read file content
const content = fs.readFileSync(inputFile, 'utf-8');
const content = fs.readFileSync(actualFile, 'utf-8');
// Generate relative path for better source map references
const relativePath = path.relative(process.cwd(), inputFile);
const relativePath = displayPath
? path.relative(process.cwd(), displayPath)
: path.relative(process.cwd(), actualFile);
// Add file separator comment
rootNode.add(`/* === ${relativePath} === */\n`);
// Add file separator comment with (babel) suffix if transformed
const banner = isBabel
? `/* === ${relativePath} (babel) === */\n`
: `/* === ${relativePath} === */\n`;
rootNode.add(banner);
// Extract sourcemap if present
const { content: cleanContent, map } = extractSourceMap(content, relativePath);

View File

@@ -19,7 +19,7 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top shadow-sm">
<div class="container-fluid">
{{-- Brand --}}
<a class="navbar-brand fw-bold" href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">
<a class="navbar-brand fw-bold" href="{{ Rsx::Route('{{ module_class }}_Index_Controller') }}">
{{ module_title }}
</a>
@@ -38,7 +38,7 @@
--}}
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}') || request()->is('{{ module_name }}/index') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">
href="{{ Rsx::Route('{{ module_class }}_Index_Controller') }}">
<i class="bi bi-house-door"></i> Home
</a>
</li>
@@ -47,7 +47,7 @@
{{--
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}/feature*') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ module_class }}_Feature_Controller')->url() }}">
href="{{ Rsx::Route('{{ module_class }}_Feature_Controller') }}">
<i class="bi bi-star"></i> Feature
</a>
</li>
@@ -71,13 +71,13 @@
<i class="bi bi-gear"></i> Settings
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ Rsx::Route('Login_Index_Controller', '#logout')->url() }}">
<li><a class="dropdown-item" href="{{ Rsx::Route('Login_Index_Controller', '#logout') }}">
<i class="bi bi-box-arrow-right"></i> Sign Out
</a></li>
</ul>
</div>
@else
<a class="btn btn-outline-light btn-sm" href="{{ Rsx::Route('Login_Index_Controller', '#show_login')->url() }}">
<a class="btn btn-outline-light btn-sm" href="{{ Rsx::Route('Login_Index_Controller', '#show_login') }}">
Login
</a>
@endif

View File

@@ -22,7 +22,7 @@
--}}
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}/{{ submodule_name }}') || request()->is('{{ module_name }}/{{ submodule_name }}/index') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ controller_prefix }}_Index_Controller')->url() }}">
href="{{ Rsx::Route('{{ controller_prefix }}_Index_Controller') }}">
<i class="bi bi-house"></i> Overview
</a>
</li>
@@ -31,7 +31,7 @@
{{--
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}/{{ submodule_name }}/feature*') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ controller_prefix }}_Feature_Controller')->url() }}">
href="{{ Rsx::Route('{{ controller_prefix }}_Feature_Controller') }}">
<i class="bi bi-star"></i> Feature
</a>
</li>
@@ -53,7 +53,7 @@
<h1 class="h3 mb-0">@yield('page_title', '{{ submodule_title }}')</h1>
<nav aria-label="breadcrumb" class="mt-2">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">{{ module_title }}</a></li>
<li class="breadcrumb-item"><a href="{{ Rsx::Route('{{ module_class }}_Index_Controller') }}">{{ module_title }}</a></li>
<li class="breadcrumb-item">{{ submodule_title }}</li>
@yield('breadcrumb_items')
</ol>

View File

View File

@@ -210,15 +210,21 @@ class Controller_BundleIntegration extends BundleIntegration_Abstract
foreach ($api_methods as $method_name => $method_info) {
$content .= " /**\n";
$content .= " * Call {$controller_name}::{$method_name} via Ajax.call()\n";
$content .= " * @param {...*} args - Arguments to pass to the method\n";
$content .= " * @param {Object} params - Parameters object to pass to the method\n";
$content .= " * @returns {Promise<*>}\n";
$content .= " */\n";
$content .= " static async {$method_name}(...args) {\n";
$content .= " return Ajax.call('{$controller_name}', '{$method_name}', args);\n";
$content .= " static async {$method_name}(params = {}) {\n";
$content .= " return Ajax.call('/_ajax/{$controller_name}/{$method_name}', params);\n";
$content .= " }\n\n";
}
$content .= "}\n";
$content .= "}\n\n";
// Add path properties after class definition
$content .= "// Path properties for type-safe routing\n";
foreach ($api_methods as $method_name => $method_info) {
$content .= "{$controller_name}.{$method_name}.path = '/_ajax/{$controller_name}/{$method_name}';\n";
}
return $content;
}

View File

@@ -42,17 +42,17 @@ abstract class Rsx_Controller_Abstract extends BaseController
return null;
}
/**
* Render a view with data
*
* @param string $view View name
* @param array $data Data to pass to view
* @return \Illuminate\View\View
*/
protected function view($view, $data = [])
{
return view($view, $data);
}
// /**
// * Render a view with data
// *
// * @param string $view View name
// * @param array $data Data to pass to view
// * @return \Illuminate\View\View
// */
// protected function view($view, $data = [])
// {
// return view($view, $data);
// }
/**
* Return a redirect response

View File

@@ -0,0 +1,58 @@
<?php
namespace App\RSpade\Core\Data;
use Illuminate\Http\Request;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
use App\RSpade\Core\Models\Country_Model;
use App\RSpade\Core\Models\Region_Model;
/**
* RSX:USE
* Rsx_Reference_Data_Controller - Provides reference data for forms (countries, states, etc.)
*
* Ajax endpoints that return standardized reference data for dropdowns and forms.
* Returns data in format compatible with Select_Input widgets: [{value: 'code', label: 'name'}, ...]
*
* Usage from widgets:
* let countries = await Rsx_Reference_Data_Controller.countries();
* let states = await Rsx_Reference_Data_Controller.states({country: 'US'});
*/
class Rsx_Reference_Data_Controller extends Rsx_Controller_Abstract
{
/**
* Get list of all countries with ISO codes
* Queries Country_Model database for ISO 3166-1 country data
* Returns array of {value: country_code, label: country_name} sorted alphabetically
*/
#[Ajax_Endpoint]
public static function countries(Request $request, array $params = []): array
{
return Country_Model::enabled()
->orderBy('name')
->get()
->map(fn($c) => ['value' => $c->alpha2, 'label' => $c->name])
->toArray();
}
/**
* Get list of states/provinces for a country
* Queries Region_Model database for ISO 3166-2 subdivision data
*
* @param Request $request
* @param array $params - Expected: ['country' => 'US']
* @return array
*/
#[Ajax_Endpoint]
public static function states(Request $request, array $params = []): array
{
$country = $params['country'] ?? 'US';
return Region_Model::where('country_alpha2', $country)
->enabled()
->orderBy('name')
->get()
->map(fn($r) => ['value' => $r->code, 'label' => $r->name])
->toArray();
}
}

View File

@@ -0,0 +1 @@
[2025-10-30 05:37:44] rsx:refactor:rename_php_class Rsx_Reference_Data Rsx_Reference_Data_Controller

View File

@@ -227,6 +227,7 @@ abstract class Rsx_Site_Model_Abstract extends Rsx_Model_Abstract
}
}
/**
* Boot the model and add global scope for site_id
*/
@@ -287,6 +288,11 @@ abstract class Rsx_Site_Model_Abstract extends Rsx_Model_Abstract
// After retrieving records, validate they belong to current site
static::retrieved(function ($model) {
if (static::$apply_site_scope) {
// Skip check if site_id wasn't selected (developer using ->get(['specific', 'columns']))
if (!isset($model->site_id)) {
return;
}
$current_site_id = static::get_current_site_id();
// This shouldn't happen if global scope is working, but double-check

View File

@@ -166,7 +166,7 @@ class Dispatcher
$params = array_merge($request->query->all(), $extra_params);
// Generate proper URL using Rsx::Route
$proper_url = Rsx::Route($controller_name, $action_name)->url($params);
$proper_url = Rsx::Route($controller_name, $action_name, $params);
console_debug('DISPATCH', 'Redirecting GET to proper route:', $proper_url);
@@ -1100,7 +1100,7 @@ class Dispatcher
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
);
}
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index')->url();
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index');
if ($message) {
Rsx::flash_error($message);
}

View File

@@ -246,20 +246,16 @@ try {
process.exit(1);
}
// Add comment header with file information
const header = `/* Transformed from: ${hashPath} (hash: ${fileHash}) */\n`;
const output = header + result.code;
// Output result
// Output result (no banner - concat-js.js handles that)
if (jsonOutput) {
console.log(JSON.stringify({
status: 'success',
result: output,
result: result.code,
file: filePath,
hash: fileHash
}));
} else {
console.log(output);
console.log(result.code);
}
} catch (error) {

View File

@@ -3,37 +3,143 @@
/**
* Client-side Ajax class for making API calls to RSX controllers
*
* Mirrors the PHP Ajax::call (Ajax::internal) functionality for browser-side JavaScript
* Automatically batches multiple calls into single HTTP requests to reduce network overhead.
* Batches up to 20 calls or flushes after setTimeout(0) debounce.
*/
class Ajax {
/**
* Make an AJAX call to an RSX controller action
*
* All calls are automatically batched using Rsx_Ajax_Batch unless
* window.rsxapp.ajax_disable_batching is true (for debugging).
*
* @param {string} controller - The controller class name (e.g., 'User_Controller')
* @param {string} action - The action method name (e.g., 'get_profile')
* @param {object} params - Parameters to send to the action
* @returns {Promise} - Resolves with the return value, rejects with error
* Initialize Ajax system
* Called automatically when class is loaded
*/
static async call(controller, action, params = {}) {
// Route through batch system
return Rsx_Ajax_Batch.call(controller, action, params);
static _on_framework_core_init() {
// Queue of pending calls waiting to be batched
Ajax._pending_calls = {};
// Timer for batching flush
Ajax._flush_timeout = null;
// Call counter for generating unique call IDs
Ajax._call_counter = 0;
// Maximum batch size before forcing immediate flush
Ajax.MAX_BATCH_SIZE = 20;
// Debounce time in milliseconds
Ajax.DEBOUNCE_MS = 0;
}
/**
* DEPRECATED: Direct call implementation (preserved for reference)
* This is now handled by Rsx_Ajax_Batch
* Make an AJAX call to an RSX controller action
*
* All calls are automatically batched unless window.rsxapp.ajax_disable_batching is true.
*
* @param {string|object|function} url - The Ajax URL (e.g., '/_ajax/Controller_Name/action_name') or an object/function with a .path property
* @param {object} params - Parameters to send to the action
* @returns {Promise} - Resolves with the return value, rejects with error
*/
static async call(url, params = {}) {
// If url is an object or function with a .path property, use that as the URL
if (url && typeof url === 'object' && url.path) {
url = url.path;
} else if (url && typeof url === 'function' && url.path) {
url = url.path;
}
// Validate url is a non-empty string
if (typeof url !== 'string' || url.length === 0) {
throw new Error('Ajax.call() requires a non-empty string URL or an object/function with a .path property');
}
// Extract controller and action from URL
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
console.log('Ajax:', controller, action, params);
// Check if batching is disabled for debugging
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
return Ajax._call_direct(controller, action, params);
}
return Ajax._call_batch(controller, action, params);
}
/**
* Make a batched Ajax call
* @private
*/
static _call_batch(controller, action, params = {}) {
console.log('Ajax Batch:', controller, action, params);
return new Promise((resolve, reject) => {
// Generate call key for deduplication
const call_key = Ajax._generate_call_key(controller, action, params);
// Check if this exact call is already pending
if (Ajax._pending_calls[call_key]) {
const existing_call = Ajax._pending_calls[call_key];
// If call already completed (cached), return immediately
if (existing_call.is_complete) {
if (existing_call.is_error) {
reject(existing_call.error);
} else {
resolve(existing_call.result);
}
return;
}
// Call is pending, add this promise to callbacks
existing_call.callbacks.push({ resolve, reject });
return;
}
// Create new pending call
const call_id = Ajax._call_counter++;
const pending_call = {
call_id: call_id,
call_key: call_key,
controller: controller,
action: action,
params: params,
callbacks: [{ resolve, reject }],
is_complete: false,
is_error: false,
result: null,
error: null,
};
// Add to pending queue
Ajax._pending_calls[call_key] = pending_call;
// Count pending calls
const pending_count = Object.keys(Ajax._pending_calls).filter((key) => !Ajax._pending_calls[key].is_complete).length;
// If we've hit the batch size limit, flush immediately
if (pending_count >= Ajax.MAX_BATCH_SIZE) {
clearTimeout(Ajax._flush_timeout);
Ajax._flush_timeout = null;
Ajax._flush_pending_calls();
} else {
// Schedule batch flush with debounce
clearTimeout(Ajax._flush_timeout);
Ajax._flush_timeout = setTimeout(() => {
Ajax._flush_pending_calls();
}, Ajax.DEBOUNCE_MS);
}
});
}
/**
* Make a direct (non-batched) Ajax call
* @private
*/
static async _call_direct(controller, action, params = {}) {
// Build the endpoint URL
// Construct URL from controller and action
const url = `/_ajax/${controller}/${action}`;
// Log the AJAX call using console_debug
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug('AJAX', `Calling ${controller}.${action}`, params);
Debugger.console_debug('AJAX', `Calling ${controller}.${action} (unbatched)`, params);
}
return new Promise((resolve, reject) => {
@@ -42,27 +148,24 @@ class Ajax {
method: 'POST',
data: params,
dataType: 'json',
__local_integration: true, // Bypass $.ajax override - this is the official Ajax endpoint pattern
__local_integration: true, // Bypass $.ajax override
success: (response) => {
// Handle console_debug messages if present
// Handle console_debug messages
if (response.console_debug && Array.isArray(response.console_debug)) {
response.console_debug.forEach((msg) => {
// Messages must be structured as [channel, [arguments]]
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
// Output with channel as first argument, then spread the arguments
console.log(channel, ...args);
});
}
// Check if the response was successful
if (response.success === true) {
// Process the return value to instantiate any ORM models
const processedValue = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
// Return the processed value
resolve(processedValue);
// @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value
const processed_value = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
resolve(processed_value);
} else {
// Handle error responses
const error_type = response.error_type || 'unknown_error';
@@ -75,7 +178,6 @@ class Ajax {
console.error(
'The user is no longer authenticated, this is a placeholder for future code which handles this scenario.'
);
// Create an error object similar to PHP exceptions
const auth_error = new Error(reason);
auth_error.type = 'auth_required';
auth_error.details = details;
@@ -93,7 +195,6 @@ class Ajax {
break;
case 'response_form_error':
// Form validation errors
const form_error = new Error(reason);
form_error.type = 'form_error';
form_error.details = details;
@@ -101,7 +202,6 @@ class Ajax {
break;
case 'response_fatal_error':
// Fatal errors
const fatal_error = new Error(reason);
fatal_error.type = 'fatal_error';
fatal_error.details = details;
@@ -118,7 +218,6 @@ class Ajax {
break;
default:
// Unknown error type
const generic_error = new Error(reason);
generic_error.type = error_type;
generic_error.details = details;
@@ -128,25 +227,7 @@ class Ajax {
}
},
error: (xhr, status, error) => {
// Handle network or server errors
let error_message = 'Network or server error';
if (xhr.responseJSON && xhr.responseJSON.message) {
error_message = xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
error_message = response.message;
}
} catch (e) {
// If response is not JSON, use the status text
error_message = `${status}: ${error}`;
}
} else {
error_message = `${status}: ${error}`;
}
const error_message = Ajax._extract_error_message(xhr);
const network_error = new Error(error_message);
network_error.type = 'network_error';
network_error.status = xhr.status;
@@ -169,15 +250,182 @@ class Ajax {
});
}
/**
* Flush all pending calls by sending batch request
* @private
*/
static async _flush_pending_calls() {
// Collect all pending calls
const calls_to_send = [];
const call_map = {}; // Map call_id to pending_call object
for (const call_key in Ajax._pending_calls) {
const pending_call = Ajax._pending_calls[call_key];
if (!pending_call.is_complete) {
calls_to_send.push({
call_id: pending_call.call_id,
controller: pending_call.controller,
action: pending_call.action,
params: pending_call.params,
});
call_map[pending_call.call_id] = pending_call;
}
}
// Nothing to send
if (calls_to_send.length === 0) {
return;
}
// Log batch for debugging
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug(
'AJAX_BATCH',
`Sending batch of ${calls_to_send.length} calls`,
calls_to_send.map((c) => `${c.controller}.${c.action}`)
);
}
try {
// Send batch request
const response = await $.ajax({
url: '/_ajax/_batch',
method: 'POST',
data: { batch_calls: JSON.stringify(calls_to_send) },
dataType: 'json',
__local_integration: true, // Bypass $.ajax override
});
// Process batch response
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... }
for (const response_key in response) {
if (!response_key.startsWith('C_')) {
continue;
}
const call_id = parseInt(response_key.substring(2), 10);
const call_response = response[response_key];
const pending_call = call_map[call_id];
if (!pending_call) {
console.error('Received response for unknown call_id:', call_id);
continue;
}
// Handle console_debug messages if present
if (call_response.console_debug && Array.isArray(call_response.console_debug)) {
call_response.console_debug.forEach((msg) => {
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
// Mark call as complete
pending_call.is_complete = true;
// Check if successful
if (call_response.success === true) {
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
const processed_value = Rsx_Js_Model._instantiate_models_recursive(call_response._ajax_return_value);
pending_call.result = processed_value;
// Resolve all callbacks
pending_call.callbacks.forEach(({ resolve }) => {
resolve(processed_value);
});
} else {
// Handle error
const error_type = call_response.error_type || 'unknown_error';
const reason = call_response.reason || 'Unknown error occurred';
const details = call_response.details || {};
const error = new Error(reason);
error.type = error_type;
error.details = details;
pending_call.is_error = true;
pending_call.error = error;
// Reject all callbacks
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
}
} catch (xhr_error) {
// Network or server error - reject all pending calls
const error_message = Ajax._extract_error_message(xhr_error);
const error = new Error(error_message);
error.type = 'network_error';
for (const call_id in call_map) {
const pending_call = call_map[call_id];
pending_call.is_complete = true;
pending_call.is_error = true;
pending_call.error = error;
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
console.error('Batch Ajax request failed:', error_message);
}
}
/**
* Generate a unique key for deduplicating calls
* @private
*/
static _generate_call_key(controller, action, params) {
// Create a stable string representation of the call
// Sort params keys for consistent hashing
const sorted_params = {};
Object.keys(params)
.sort()
.forEach((key) => {
sorted_params[key] = params[key];
});
return `${controller}::${action}::${JSON.stringify(sorted_params)}`;
}
/**
* Extract error message from jQuery XHR object
* @private
*/
static _extract_error_message(xhr) {
if (xhr.responseJSON && xhr.responseJSON.message) {
return xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
return response.message;
}
} catch (e) {
// Not JSON
}
}
return `${xhr.status}: ${xhr.statusText || 'Unknown error'}`;
}
/**
* Parses an AJAX URL into controller and action
* @param {string} url - URL in format '/_ajax/Controller_Name/action_name'
* Supports both /_ajax/ and /_/ URL prefixes
* @param {string} url - URL in format '/_ajax/Controller_Name/action_name' or '/_/Controller_Name/action_name'
* @returns {Object} Object with {controller: string, action: string}
* @throws {Error} If URL doesn't start with /_ajax or has invalid structure
* @throws {Error} If URL doesn't start with /_ajax or /_ or has invalid structure
*/
static ajax_url_to_controller_action(url) {
if (!url.startsWith('/_ajax')) {
throw new Error(`URL must start with /_ajax, got: ${url}`);
if (!url.startsWith('/_ajax') && !url.startsWith('/_/')) {
throw new Error(`URL must start with /_ajax or /_, got: ${url}`);
}
const parts = url.split('/').filter((part) => part !== '');
@@ -195,4 +443,11 @@ class Ajax {
return { controller, action };
}
/**
* Auto-initialize static properties when class is first loaded
*/
static on_core_define() {
Ajax._on_framework_core_init();
}
}

View File

@@ -249,13 +249,7 @@ class Debugger {
Debugger._console_timer = null;
try {
await $.ajax({
url: '/_ajax/Debugger_Controller/log_console_messages',
method: 'POST',
data: JSON.stringify({ messages: messages }),
contentType: 'application/json',
dataType: 'json',
});
return Ajax.call('Debugger_Controller', 'log_console_messages', { messages: messages });
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send console_debug messages to server:', error);
@@ -276,13 +270,8 @@ class Debugger {
Debugger._error_batch_count++;
try {
await $.ajax({
url: '/_ajax/Debugger_Controller/log_browser_errors',
method: 'POST',
data: JSON.stringify({ errors: errors }),
contentType: 'application/json',
dataType: 'json',
});
// Debugger_Controller.log_browser_errors({ errors: errors });
return Ajax.call('Debugger_Controller', 'log_browser_errors', { errors: errors });
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send browser errors to server:', error);

View File

@@ -1,7 +1,7 @@
/**
* Form utilities for validation and error handling
*/
class Form {
class Form_Utils {
/**
* Framework initialization hook to register jQuery plugin
* Creates $.fn.ajax_submit() for form elements
@@ -22,7 +22,7 @@ class Form {
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
return Form.ajax_submit($element, controller, action, options);
return Form_Utils.ajax_submit($element, controller, action, options);
};
}
@@ -57,36 +57,72 @@ class Form {
const $parent = $(parent_selector);
// Reset the form errors before applying new ones
Form.reset_form_errors(parent_selector);
Form_Utils.reset_form_errors(parent_selector);
// Normalize input to standard format
const normalized = Form._normalize_errors(errors);
const normalized = Form_Utils._normalize_errors(errors);
return new Promise((resolve) => {
let animations = [];
if (normalized.type === 'string') {
// Single error message
animations = Form._apply_general_errors($parent, normalized.data);
animations = Form_Utils._apply_general_errors($parent, normalized.data);
} else if (normalized.type === 'array') {
// Array of error messages
const deduplicated = Form._deduplicate_errors(normalized.data);
animations = Form._apply_general_errors($parent, deduplicated);
const deduplicated = Form_Utils._deduplicate_errors(normalized.data);
animations = Form_Utils._apply_general_errors($parent, deduplicated);
} else if (normalized.type === 'fields') {
// Field-specific errors
const result = Form._apply_field_errors($parent, normalized.data);
const result = Form_Utils._apply_field_errors($parent, normalized.data);
animations = result.animations;
// Show unmatched errors as general alert
const unmatched_deduplicated = Form._deduplicate_errors(result.unmatched);
if (Object.keys(unmatched_deduplicated).length > 0) {
const unmatched_animations = Form._apply_general_errors($parent, unmatched_deduplicated);
animations.push(...unmatched_animations);
// Count matched fields
const matched_count = Object.keys(normalized.data).length - Object.keys(result.unmatched).length;
const unmatched_deduplicated = Form_Utils._deduplicate_errors(result.unmatched);
const unmatched_count = Object.keys(unmatched_deduplicated).length;
// Show summary alert if there are any field errors (matched or unmatched)
if (matched_count > 0 || unmatched_count > 0) {
// Build summary message
let summary_msg = '';
if (matched_count > 0) {
summary_msg = matched_count === 1
? 'Please correct the error highlighted below.'
: 'Please correct the errors highlighted below.';
}
// If there are unmatched errors, add them as a bulleted list
if (unmatched_count > 0) {
const summary_animations = Form_Utils._apply_combined_error($parent, summary_msg, unmatched_deduplicated);
animations.push(...summary_animations);
} else {
// Just the summary message, no unmatched errors
const summary_animations = Form_Utils._apply_general_errors($parent, summary_msg);
animations.push(...summary_animations);
}
}
}
// Resolve the promise once all animations are complete
Promise.all(animations).then(resolve);
Promise.all(animations).then(() => {
// Scroll to error container if it exists
const $error_container = $parent.find('[data-id="error_container"]').first();
if ($error_container.length > 0) {
const container_top = $error_container.offset().top;
// Calculate fixed header offset
const fixed_header_height = Form_Utils._get_fixed_header_height();
// Scroll to position error container 20px below any fixed headers
const target_scroll = container_top - fixed_header_height - 20;
$('html, body').animate({
scrollTop: target_scroll
}, 500);
}
resolve();
});
});
}
@@ -97,7 +133,7 @@ class Form {
static reset(form_selector) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
Form.reset_form_errors(form_selector);
Form_Utils.reset_form_errors(form_selector);
$form.trigger('reset');
}
@@ -128,9 +164,9 @@ class Form {
*/
static async ajax_submit(form_selector, controller, action, options = {}) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
const form_data = Form.serialize($form);
const form_data = Form_Utils.serialize($form);
Form.reset_form_errors(form_selector);
Form_Utils.reset_form_errors(form_selector);
try {
const response = await Ajax.call(controller, action, form_data);
@@ -142,9 +178,9 @@ class Form {
return response;
} catch (error) {
if (error.type === 'form_error' && error.details) {
await Form.apply_form_errors(form_selector, error.details);
await Form_Utils.apply_form_errors(form_selector, error.details);
} else {
await Form.apply_form_errors(form_selector, error.message || 'An error occurred');
await Form_Utils.apply_form_errors(form_selector, error.message || 'An error occurred');
}
if (options.on_error) {
@@ -200,7 +236,7 @@ class Form {
}
// Array with object as first element - extract it
if (errors.length > 0 && typeof errors[0] === 'object') {
return Form._normalize_errors(errors[0]);
return Form_Utils._normalize_errors(errors[0]);
}
// Empty or mixed array
return { type: 'array', data: [] };
@@ -211,7 +247,7 @@ class Form {
// Unwrap {errors: {...}} or {error: {...}}
const unwrapped = errors.errors || errors.error;
if (unwrapped) {
return Form._normalize_errors(unwrapped);
return Form_Utils._normalize_errors(unwrapped);
}
// Convert Laravel validator format {field: [msg1, msg2]} to {field: msg1}
@@ -299,6 +335,46 @@ class Form {
return { animations, unmatched };
}
/**
* Applies combined error message with summary and unmatched field errors
* @param {jQuery} $parent - Parent element containing form
* @param {string} summary_msg - Summary message (e.g., "Please correct the errors below")
* @param {Object} unmatched_errors - Object of field errors that couldn't be matched to fields
* @returns {Array} Array of animation promises
* @private
*/
static _apply_combined_error($parent, summary_msg, unmatched_errors) {
const animations = [];
const $error_container = $parent.find('[data-id="error_container"]').first();
const $target = $error_container.length > 0 ? $error_container : $parent;
// Create alert with summary message and bulleted list of unmatched errors
const $alert = $('<div class="alert alert-danger" role="alert"></div>');
// Add summary message if provided
if (summary_msg) {
$('<p class="mb-2"></p>').text(summary_msg).appendTo($alert);
}
// Add unmatched errors as bulleted list
if (Object.keys(unmatched_errors).length > 0) {
const $list = $('<ul class="mb-0"></ul>');
for (const field_name in unmatched_errors) {
const error_msg = unmatched_errors[field_name];
$('<li></li>').html(error_msg).appendTo($list);
}
$list.appendTo($alert);
}
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
return animations;
}
/**
* Applies general error messages as alert box
* @param {jQuery} $parent - Parent element to prepend alert to
@@ -309,10 +385,18 @@ class Form {
static _apply_general_errors($parent, messages) {
const animations = [];
// Look for a specific error container div (e.g., in Rsx_Form component)
const $error_container = $parent.find('[data-id="error_container"]').first();
const $target = $error_container.length > 0 ? $error_container : $parent;
if (typeof messages === 'string') {
// Single error - simple alert without list
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
animations.push($alert.hide().prependTo($parent).fadeIn(300).promise());
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
} else if (Array.isArray(messages) && messages.length > 0) {
// Multiple errors - bulleted list
const $alert = $('<div class="alert alert-danger" role="alert"><ul class="mb-0"></ul></div>');
@@ -323,17 +407,64 @@ class Form {
$('<li></li>').html(text).appendTo($list);
});
animations.push($alert.hide().prependTo($parent).fadeIn(300).promise());
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
} else if (typeof messages === 'object' && !Array.isArray(messages)) {
// Object of unmatched field errors - convert to array
const error_list = Object.values(messages)
.map((v) => String(v).trim())
.filter((v) => v);
if (error_list.length > 0) {
return Form._apply_general_errors($parent, error_list);
return Form_Utils._apply_general_errors($parent, error_list);
}
}
return animations;
}
/**
* Calculates the total height of fixed/sticky headers at the top of the page
* @returns {number} Total height in pixels of fixed top elements
* @private
*/
static _get_fixed_header_height() {
let total_height = 0;
// Find all fixed or sticky positioned elements
$('*').each(function() {
const $el = $(this);
const position = $el.css('position');
// Only check fixed or sticky elements
if (position !== 'fixed' && position !== 'sticky') {
return;
}
// Check if element is positioned at or near the top
const top = parseInt($el.css('top')) || 0;
if (top > 50) {
return; // Not a top header
}
// Check if element is visible
if (!$el.is(':visible')) {
return;
}
// Check if element spans significant width (likely a header/navbar)
const width = $el.outerWidth();
const viewport_width = $(window).width();
if (width < viewport_width * 0.5) {
return; // Too narrow to be a header
}
// Add this element's height
total_height += $el.outerHeight();
});
return total_height;
}
}

View File

@@ -150,11 +150,10 @@ class Rsx {
}
/**
* Create a route proxy for type-safe URL generation
* Generate URL for a controller route
*
* This method creates a route proxy that can generate URLs for a specific controller action.
* The proxy ensures all required route parameters are provided and handles extra parameters
* as query string values.
* This method generates URLs for controller actions by looking up route patterns
* and replacing parameters. It handles both regular routes and Ajax endpoints.
*
* If the route is not found in the route definitions, a default pattern is used:
* `/_/{controller}/{action}` with all parameters appended as query strings.
@@ -162,56 +161,130 @@ class Rsx {
* Usage examples:
* ```javascript
* // Simple route without parameters (defaults to 'index' action)
* const url = Rsx.Route('Frontend_Index_Controller').url();
* const url = Rsx.Route('Frontend_Index_Controller');
* // Returns: /dashboard
*
* // Route with explicit action
* const url = Rsx.Route('Frontend_Index_Controller', 'index').url();
* const url = Rsx.Route('Frontend_Index_Controller', 'index');
* // Returns: /dashboard
*
* // Route with required parameter
* const url = Rsx.Route('Frontend_Client_View_Controller').url({id: 'C001'});
* // Route with integer parameter (sets 'id')
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', 123);
* // Returns: /clients/view/123
*
* // Route with named parameters (object)
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {id: 'C001'});
* // Returns: /clients/view/C001
*
* // Route with required and query parameters
* const url = Rsx.Route('Frontend_Client_View_Controller').url({
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {
* id: 'C001',
* tab: 'history'
* });
* // Returns: /clients/view/C001?tab=history
*
* // Route not found - uses default pattern
* const url = Rsx.Route('Unimplemented_Controller', 'some_action').url({foo: 'bar'});
* const url = Rsx.Route('Unimplemented_Controller', 'some_action', {foo: 'bar'});
* // Returns: /_/Unimplemented_Controller/some_action?foo=bar
*
* // Generate absolute URL
* const absolute = Rsx.Route('Frontend_Index_Controller').absolute_url();
* // Returns: https://example.com/dashboard
*
* // Navigate to route
* Rsx.Route('Frontend_Index_Controller').navigate();
* // Redirects browser to /dashboard
*
* // Check if route is current
* if (Rsx.Route('Frontend_Index_Controller').is_current()) {
* // This is the currently executing route
* }
* // Placeholder route
* const url = Rsx.Route('Future_Controller', '#index');
* // Returns: #
* ```
*
* @param {string} class_name The controller class name (e.g., 'User_Controller')
* @param {string} [action_name='index'] The action/method name (defaults to 'index')
* @returns {Rsx_Route_Proxy} Route proxy instance for URL generation
* @param {string} [action_name='index'] The action/method name (defaults to 'index'). Use '#action' for placeholders.
* @param {number|Object} [params=null] Route parameters. Integer sets 'id', object provides named params.
* @returns {string} The generated URL
*/
static Route(class_name, action_name = 'index') {
// Check if route exists in definitions
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
const pattern = Rsx._routes[class_name][action_name];
return new Rsx_Route_Proxy(class_name, action_name, pattern);
static Route(class_name, action_name = 'index', params = null) {
// Normalize params to object
let params_obj = {};
if (typeof params === 'number') {
params_obj = { id: params };
} else if (params && typeof params === 'object') {
params_obj = params;
} else if (params !== null && params !== undefined) {
throw new Error('Params must be number, object, or null');
}
// Route not found - use default pattern /_/{controller}/{action}
const default_pattern = `/_/${class_name}/${action_name}`;
return new Rsx_Route_Proxy(class_name, action_name, default_pattern);
// Placeholder route: action starts with # means unimplemented/scaffolding
if (action_name.startsWith('#')) {
return '#';
}
// Check if route exists in definitions
let pattern;
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
pattern = Rsx._routes[class_name][action_name];
} else {
// Route not found - use default pattern /_/{controller}/{action}
pattern = `/_/${class_name}/${action_name}`;
}
// Generate URL from pattern
return Rsx._generate_url_from_pattern(pattern, params_obj);
}
/**
* Generate URL from route pattern by replacing parameters
*
* @param {string} pattern The route pattern (e.g., '/users/:id/view')
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated URL
*/
static _generate_url_from_pattern(pattern, params) {
// Extract required parameters from the pattern
const required_params = [];
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
// Remove the : prefix from each match
for (const match of matches) {
required_params.push(match.substring(1));
}
}
// Check for required parameters
const missing = [];
for (const required of required_params) {
if (!(required in params)) {
missing.push(required);
}
}
if (missing.length > 0) {
throw new Error(`Required parameters [${missing.join(', ')}] are missing for route ${pattern}`);
}
// Build the URL by replacing parameters
let url = pattern;
const used_params = {};
for (const param_name of required_params) {
const value = params[param_name];
// URL encode the value
const encoded_value = encodeURIComponent(value);
url = url.replace(':' + param_name, encoded_value);
used_params[param_name] = true;
}
// Collect any extra parameters for query string
const query_params = {};
for (const key in params) {
if (!used_params[key]) {
query_params[key] = params[key];
}
}
// Append query string if there are extra parameters
if (Object.keys(query_params).length > 0) {
const query_string = Object.entries(query_params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += '?' + query_string;
}
return url;
}
/**
@@ -316,6 +389,8 @@ class Rsx {
// All phases complete
console_debug('RSX_INIT', 'Initialization complete');
// TODO: Find a good wait to wait for all jqhtml components to load, then trigger on_ready and on('ready') emulating the top level last syntax that jqhtml components operateas, but as a standard js class (such as a page class). The biggest question is, how do we efficiently choose only the top level jqhtml components. do we only consider components cretaed directly on blade templates? that seams reasonable...
// Trigger _debug_ready event - this is ONLY for tooling like rsx:debug
// DO NOT use this in application code - use on_app_ready() phase instead
// This event exists solely for debugging tools that need to run after full initialization
@@ -327,4 +402,142 @@ class Rsx {
console.error(reason);
Rsx.__stopped = true;
}
/**
* Parse URL hash into key-value object
* Handles format: #key=value&key2=value2
*
* @returns {Object} Parsed hash parameters
*/
static _parse_hash() {
const hash = window.location.hash;
if (!hash || hash === '#') {
return {};
}
// Remove leading # and parse as query string
const hash_string = hash.substring(1);
const params = {};
const pairs = hash_string.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=');
if (key) {
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
}
}
return params;
}
/**
* Serialize object into URL hash format
* Produces format: #key=value&key2=value2
*
* @param {Object} params Key-value pairs to encode
* @returns {string} Encoded hash string (with leading #, or empty string)
*/
static _serialize_hash(params) {
const pairs = [];
for (const key in params) {
const value = params[key];
if (value !== null && value !== undefined && value !== '') {
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return pairs.length > 0 ? '#' + pairs.join('&') : '';
}
/**
* Get all page state from URL hash
*
* Usage:
* ```javascript
* const state = Rsx.get_all_page_state();
* // Returns: {dg_page: '2', dg_sort: 'name'}
* ```
*
* @returns {Object} All hash parameters as key-value pairs
*/
static get_all_page_state() {
return Rsx._parse_hash();
}
/**
* Get single value from URL hash state
*
* Usage:
* ```javascript
* const page = Rsx.get_page_state('dg_page');
* // Returns: '2' or null if not set
* ```
*
* @param {string} key The key to retrieve
* @returns {string|null} The value or null if not found
*/
static get_page_state(key) {
const state = Rsx._parse_hash();
return state[key] ?? null;
}
/**
* Set single value in URL hash state (replaces history, doesn't add)
*
* Usage:
* ```javascript
* Rsx.set_page_state('dg_page', 2);
* // URL becomes: http://example.com/page#dg_page=2
*
* Rsx.set_page_state('dg_page', null); // Remove key
* ```
*
* @param {string} key The key to set
* @param {string|number|null} value The value (null/empty removes the key)
*/
static set_page_state(key, value) {
const state = Rsx._parse_hash();
// Update or remove the key
if (value === null || value === undefined || value === '') {
delete state[key];
} else {
state[key] = String(value);
}
// Update URL without adding history
const new_hash = Rsx._serialize_hash(state);
const url = window.location.pathname + window.location.search + new_hash;
history.replaceState(null, '', url);
}
/**
* Set multiple values in URL hash state at once
*
* Usage:
* ```javascript
* Rsx.set_all_page_state({dg_page: 2, dg_sort: 'name'});
* // URL becomes: http://example.com/page#dg_page=2&dg_sort=name
* ```
*
* @param {Object} new_state Object with key-value pairs to set
*/
static set_all_page_state(new_state) {
const state = Rsx._parse_hash();
// Merge new state
for (const key in new_state) {
const value = new_state[key];
if (value === null || value === undefined || value === '') {
delete state[key];
} else {
state[key] = String(value);
}
}
// Update URL without adding history
const new_hash = Rsx._serialize_hash(state);
const url = window.location.pathname + window.location.search + new_hash;
history.replaceState(null, '', url);
}
}

View File

@@ -1,348 +0,0 @@
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Rsx_Ajax_Batch - Batches multiple Ajax calls into single HTTP request
*
* Inspired by RS3's batch API system. All Ajax.call() requests are batched
* together into a single POST to /_ajax/_batch. This dramatically reduces
* HTTP requests from N to 1-3 per page.
*
* Key behaviors:
* - Batches up to 20 calls, then flushes immediately (MAX_BATCH_SIZE)
* - New calls after flush go into next batch
* - Uses setTimeout(0) debounce when under batch size limit (DEBOUNCE_MS)
* - Deduplicates identical calls (same controller/action/params)
* - Returns cached results for duplicate calls
* - Can be disabled via window.rsxapp.ajax_disable_batching for debugging
*/
class Rsx_Ajax_Batch {
/**
* Initialize batch system
* Called automatically when class is loaded
*/
static init() {
// Queue of pending calls waiting to be batched
Rsx_Ajax_Batch._pending_calls = {};
// Timer for batching flush
Rsx_Ajax_Batch._flush_timeout = null;
// Call counter for generating unique call IDs
Rsx_Ajax_Batch._call_counter = 0;
// Maximum batch size before forcing immediate flush
Rsx_Ajax_Batch.MAX_BATCH_SIZE = 20;
// Debounce time in milliseconds
Rsx_Ajax_Batch.DEBOUNCE_MS = 0;
}
/**
* Queue an Ajax call for batching
*
* @param {string} controller - Controller class name
* @param {string} action - Action method name
* @param {object} params - Parameters to send
* @returns {Promise} - Resolves with return value or rejects with error
*/
static call(controller, action, params = {}) {
// Check if batching is disabled for debugging
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
// Make individual request immediately
return Rsx_Ajax_Batch._make_individual_request(controller, action, params);
}
return new Promise((resolve, reject) => {
// Generate call key for deduplication
// Same controller + action + params = same call
const call_key = Rsx_Ajax_Batch._generate_call_key(controller, action, params);
// Check if this exact call is already pending
if (Rsx_Ajax_Batch._pending_calls[call_key]) {
const existing_call = Rsx_Ajax_Batch._pending_calls[call_key];
// If call already completed (cached), return immediately
if (existing_call.is_complete) {
if (existing_call.is_error) {
reject(existing_call.error);
} else {
resolve(existing_call.result);
}
return;
}
// Call is pending, add this promise to callbacks
existing_call.callbacks.push({ resolve, reject });
return;
}
// Create new pending call
const call_id = Rsx_Ajax_Batch._call_counter++;
const pending_call = {
call_id: call_id,
call_key: call_key,
controller: controller,
action: action,
params: params,
callbacks: [{ resolve, reject }],
is_complete: false,
is_error: false,
result: null,
error: null,
};
// Add to pending queue
Rsx_Ajax_Batch._pending_calls[call_key] = pending_call;
// Count pending calls
const pending_count = Object.keys(Rsx_Ajax_Batch._pending_calls).filter(
(key) => !Rsx_Ajax_Batch._pending_calls[key].is_complete
).length;
// If we've hit the batch size limit, flush immediately
if (pending_count >= Rsx_Ajax_Batch.MAX_BATCH_SIZE) {
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
Rsx_Ajax_Batch._flush_timeout = null;
Rsx_Ajax_Batch._flush_pending_calls();
} else {
// Schedule batch flush with debounce
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
Rsx_Ajax_Batch._flush_timeout = setTimeout(() => {
Rsx_Ajax_Batch._flush_pending_calls();
}, Rsx_Ajax_Batch.DEBOUNCE_MS);
}
});
}
/**
* Flush all pending calls by sending batch request
* @private
*/
static async _flush_pending_calls() {
// Collect all pending calls
const calls_to_send = [];
const call_map = {}; // Map call_id to pending_call object
for (const call_key in Rsx_Ajax_Batch._pending_calls) {
const pending_call = Rsx_Ajax_Batch._pending_calls[call_key];
if (!pending_call.is_complete) {
calls_to_send.push({
call_id: pending_call.call_id,
controller: pending_call.controller,
action: pending_call.action,
params: pending_call.params,
});
call_map[pending_call.call_id] = pending_call;
}
}
// Nothing to send
if (calls_to_send.length === 0) {
return;
}
// Log batch for debugging
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug(
'AJAX_BATCH',
`Sending batch of ${calls_to_send.length} calls`,
calls_to_send.map((c) => `${c.controller}.${c.action}`)
);
}
try {
// Send batch request
const response = await $.ajax({
url: '/_ajax/_batch',
method: 'POST',
data: { batch_calls: JSON.stringify(calls_to_send) },
dataType: 'json',
__local_integration: true, // Bypass $.ajax override
});
// Process batch response
// Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... }
for (const response_key in response) {
if (!response_key.startsWith('C_')) {
continue;
}
const call_id = parseInt(response_key.substring(2), 10);
const call_response = response[response_key];
const pending_call = call_map[call_id];
if (!pending_call) {
console.error('Received response for unknown call_id:', call_id);
continue;
}
// Handle console_debug messages if present
if (call_response.console_debug && Array.isArray(call_response.console_debug)) {
call_response.console_debug.forEach((msg) => {
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
// Mark call as complete
pending_call.is_complete = true;
// Check if successful
if (call_response.success === true) {
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
// Process the return value to instantiate any ORM models
const processed_value = Rsx_Js_Model._instantiate_models_recursive(call_response._ajax_return_value);
pending_call.result = processed_value;
// Resolve all callbacks
pending_call.callbacks.forEach(({ resolve }) => {
resolve(processed_value);
});
} else {
// Handle error
const error_type = call_response.error_type || 'unknown_error';
const reason = call_response.reason || 'Unknown error occurred';
const details = call_response.details || {};
const error = new Error(reason);
error.type = error_type;
error.details = details;
pending_call.is_error = true;
pending_call.error = error;
// Reject all callbacks
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
}
} catch (xhr_error) {
// Network or server error - reject all pending calls
const error_message = Rsx_Ajax_Batch._extract_error_message(xhr_error);
const error = new Error(error_message);
error.type = 'network_error';
for (const call_id in call_map) {
const pending_call = call_map[call_id];
pending_call.is_complete = true;
pending_call.is_error = true;
pending_call.error = error;
pending_call.callbacks.forEach(({ reject }) => {
reject(error);
});
}
console.error('Batch Ajax request failed:', error_message);
}
}
/**
* Make an individual Ajax request (when batching is disabled)
* @private
*/
static async _make_individual_request(controller, action, params) {
const url = `/_ajax/${controller}/${action}`;
if (typeof Debugger !== 'undefined' && Debugger.console_debug) {
Debugger.console_debug('AJAX', `Calling ${controller}.${action} (unbatched)`, params);
}
return new Promise((resolve, reject) => {
$.ajax({
url: url,
method: 'POST',
data: params,
dataType: 'json',
__local_integration: true,
success: (response) => {
// Handle console_debug messages
if (response.console_debug && Array.isArray(response.console_debug)) {
response.console_debug.forEach((msg) => {
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
if (response.success === true) {
// @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value
const processed_value = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
resolve(processed_value);
} else {
const error_type = response.error_type || 'unknown_error';
const reason = response.reason || 'Unknown error occurred';
const details = response.details || {};
const error = new Error(reason);
error.type = error_type;
error.details = details;
reject(error);
}
},
error: (xhr, status, error) => {
const error_message = Rsx_Ajax_Batch._extract_error_message(xhr);
const network_error = new Error(error_message);
network_error.type = 'network_error';
network_error.status = xhr.status;
network_error.statusText = status;
reject(network_error);
},
});
});
}
/**
* Generate a unique key for deduplicating calls
* @private
*/
static _generate_call_key(controller, action, params) {
// Create a stable string representation of the call
// Sort params keys for consistent hashing
const sorted_params = {};
Object.keys(params)
.sort()
.forEach((key) => {
sorted_params[key] = params[key];
});
return `${controller}::${action}::${JSON.stringify(sorted_params)}`;
}
/**
* Extract error message from jQuery XHR object
* @private
*/
static _extract_error_message(xhr) {
if (xhr.responseJSON && xhr.responseJSON.message) {
return xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
return response.message;
}
} catch (e) {
// Not JSON
}
}
return `${xhr.status}: ${xhr.statusText || 'Unknown error'}`;
}
/**
* Auto-initialize static properties when class is first loaded
* Called by on_core_define lifecycle hook
*/
static on_core_define() {
Rsx_Ajax_Batch.init();
}
}

View File

@@ -149,6 +149,36 @@ class Rsx_Jq_Helpers {
return this;
};
// Find related components by searching up the ancestor tree
// Like .closest() but searches within ancestors instead of matching them
$.fn.closest_sibling = function (selector) {
let $current = this;
let $parent = $current.parent();
// Keep going up the tree until we hit body
while ($parent.length > 0 && !$parent.is('body')) {
// Search within this parent for the selector
let $found = $parent.find(selector);
if ($found.length > 0) {
return $found;
}
// Move up one level
$parent = $parent.parent();
}
// If we reached body, search within body as well
if ($parent.is('body')) {
let $found = $parent.find(selector);
if ($found.length > 0) {
return $found;
}
}
// Return empty jQuery object if nothing found
return $();
};
// Override $.ajax to prevent direct AJAX calls to local server
// Developers must use the Ajax endpoint pattern: await Controller.method(params)
const native_ajax = $.ajax;

View File

@@ -1,164 +0,0 @@
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
/**
* Rsx_Route_Proxy - Type-safe route URL generator
*
* SPECIAL ARCHITECTURAL NOTE:
* This class intentionally diverges from RSX's standard static-only pattern.
* It uses instance properties and methods to enable the syntactic sugar:
* let url = Rsx.Route('Controller_Name', 'action_name').url();
*
* Each call to Rsx.Route() creates a new instance of Rsx_Route_Proxy that
* encapsulates the specific route pattern and required parameters. This
* allows for clean method chaining and parameter validation.
*
* WHY INSTANCE-BASED:
* - Encapsulates route-specific data (pattern, params) per instance
* - Enables fluent interface for URL generation
* - Provides type safety by validating params at runtime
* - Allows method chaining: .url(), .absolute_url(), .navigate()
*
* This divergence from static classes is appropriate here because each
* route proxy represents a specific route with its own state, not a
* collection of utility methods.
*
* @Instantiatable
*/
class Rsx_Route_Proxy {
/**
* Constructor
*
* @param {string} class_name The controller class name
* @param {string} method_name The action/method name
* @param {string} pattern The route pattern
*/
constructor(class_name, method_name, pattern) {
this._class = class_name;
this._method = method_name;
this._pattern = pattern;
this._required_params = [];
// Extract required parameters from the pattern
this._extract_required_params();
}
/**
* Extract required parameters from the route pattern
*/
_extract_required_params() {
const that = this;
// Match all :param patterns in the route
const matches = that._pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
// Remove the : prefix from each match
that._required_params = matches.map((match) => match.substring(1));
}
}
/**
* Check if this route matches the current controller and action
*
* @returns {boolean} True if this is the current route
*/
is_current() {
const that = this;
// Get current controller and action from window.rsxapp if available
return that._class === window.rsxapp.current_controller && that._method === window.rsxapp.current_action;
}
/**
* Generate a relative URL for the route
*
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated URL
* @throws {Error} If required parameters are missing
*/
url(params = {}) {
const that = this;
// Check if the method name starts with '#' - indicates unimplemented route
if (that._method.startsWith('#')) {
return '#';
}
// Check for required parameters
const missing = [];
for (const required of that._required_params) {
if (!(required in params)) {
missing.push(required);
}
}
if (missing.length > 0) {
throw new Error(
`Required parameters [${missing.join(', ')}] are missing for route ` + `${that._pattern} on ${that._class}::${that._method}`
);
}
// Build the URL by replacing parameters
let url = that._pattern;
const used_params = {};
for (const param_name of that._required_params) {
const value = params[param_name];
// URL encode the value
const encoded_value = encodeURIComponent(value);
url = url.replace(':' + param_name, encoded_value);
used_params[param_name] = true;
}
// Collect any extra parameters for query string
const query_params = {};
for (const key in params) {
if (!used_params[key]) {
query_params[key] = params[key];
}
}
// Append query string if there are extra parameters
if (Object.keys(query_params).length > 0) {
const query_string = Object.entries(query_params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += '?' + query_string;
}
return url;
}
/**
* Generate an absolute URL for the route
*
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated absolute URL
* @throws {Error} If required parameters are missing
*/
absolute_url(params = {}) {
const that = this;
// Check if the method name starts with '#' - indicates unimplemented route
if (that._method.startsWith('#')) {
return '#';
}
// Get the relative URL first
const relative_url = that.url(params);
// Get the current protocol and host
const protocol = window.location.protocol;
const host = window.location.host; // includes port if non-standard
return protocol + '//' + host + relative_url;
}
/**
* Navigate to the route by setting window.location.href
*
* @param {Object} params Parameters to fill into the route
* @throws {Error} If required parameters are missing
*/
navigate(params = {}) {
const that = this;
const url = that.url(params);
window.location.href = url;
}
}

View File

@@ -38,20 +38,53 @@ function sleep(milliseconds = 0) {
*
* The function returns a promise that resolves when the next exclusive execution completes.
*
* @param {function} callback The callback function to be invoked
* Usage as function:
* const debouncedFn = debounce(myFunction, 250);
*
* Usage as decorator:
* @debounce(250)
* myMethod() { ... }
*
* @param {function|number} callback_or_delay The callback function OR delay when used as decorator
* @param {number} delay The delay in milliseconds before subsequent invocations
* @param {boolean} immediate if true, the first time the action is called, the callback executes immediately
* @returns {function} A function that when invoked, runs the callback immediately and exclusively,
*
* @decorator
*/
function debounce(callback, delay, immediate = false) {
function debounce(callback_or_delay, delay, immediate = false) {
// Decorator usage: @debounce(250) or @debounce(250, true)
// First argument is a number (the delay), returns decorator function
if (typeof callback_or_delay === 'number') {
const decorator_delay = callback_or_delay;
const decorator_immediate = delay || false;
// TC39 decorator form: receives (value, context)
return function (value, context) {
if (context.kind === 'method') {
return debounce_impl(value, decorator_delay, decorator_immediate);
}
};
}
// Function usage: debounce(fn, 250)
// First argument is a function (the callback)
const callback = callback_or_delay;
return debounce_impl(callback, delay, immediate);
}
/**
* Internal implementation of debounce logic
* @private
*/
function debounce_impl(callback, delay, immediate = false) {
let running = false;
let queued = false;
let last_end_time = 0; // timestamp of last completed run
let timer = null;
let next_args = [];
let next_context = null;
let resolve_queue = [];
let reject_queue = [];
@@ -59,15 +92,17 @@ function debounce(callback, delay, immediate = false) {
const these_resolves = resolve_queue;
const these_rejects = reject_queue;
const args = next_args;
const context = next_context;
resolve_queue = [];
reject_queue = [];
next_args = [];
next_context = null;
queued = false;
running = true;
try {
const result = await callback(...args);
const result = await callback.apply(context, args);
for (const resolve of these_resolves) resolve(result);
} catch (err) {
for (const reject of these_rejects) reject(err);
@@ -85,6 +120,7 @@ function debounce(callback, delay, immediate = false) {
return function (...args) {
next_args = args;
next_context = this;
return new Promise((resolve, reject) => {
resolve_queue.push(resolve);

View File

@@ -249,6 +249,29 @@ function str(val) {
return String(val);
}
/**
* Converts numeric strings to numbers, returns all other values unchanged
* Used when you need to ensure numeric types but don't want to force
* conversion of non-numeric values (which would become 0)
* @param {*} val - Value to convert
* @returns {*} Number if input was numeric string, otherwise unchanged
*/
function value_unless_numeric_string_then_numeric_value(val) {
// If it's already a number, return it
if (typeof val === 'number') {
return val;
}
// If it's a string and numeric, convert it
if (is_string(val) && is_numeric(val)) {
// Use parseFloat to handle both integers and floats
return parseFloat(val);
}
// Return everything else unchanged (null, objects, non-numeric strings, etc.)
return val;
}
// ============================================================================
// STRING MANIPULATION FUNCTIONS
// ============================================================================

View File

@@ -1171,7 +1171,7 @@ class Manifest
console_debug('MANIFEST', 'Cache file written successfully', [
'path' => $cache_file,
'size' => $file_size,
'permissions' => $file_perms
'permissions' => $file_perms,
]);
} else {
console_debug_force('MANIFEST', 'WARNING: Cache file does not exist after rebuild!', $cache_file);
@@ -1262,6 +1262,7 @@ class Manifest
// Extract just the class name (last part after final backslash)
$parts = explode('\\', $class_name);
return end($parts);
}
@@ -1612,146 +1613,9 @@ class Manifest
// ------------------------------------------------------------------------
// ---- Private / Protected Methods:
// ------------------------------------------------------------------------
/**
* Generate JavaScript stub files for PHP controllers with Internal API methods
* These stubs enable IDE autocomplete for Ajax.call() invocations from JavaScript
*/
protected static function _generate_js_api_stubs(): void
{
$stub_dir = storage_path('rsx-build/js-stubs');
// Create directory if it doesn't exist
if (!is_dir($stub_dir)) {
mkdir($stub_dir, 0755, true);
}
// Track generated stub files for cleanup
$generated_stubs = [];
// Process each file
foreach (static::$data['data']['files'] as $file_path => &$metadata) {
// Skip non-PHP files
if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') {
continue;
}
// Skip files without classes or public static methods
if (!isset($metadata['class']) || !isset($metadata['public_static_methods'])) {
continue;
}
// Check if this is a controller (extends Rsx_Controller_Abstract)
$class_name = $metadata['class'] ?? '';
if (!static::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
continue;
}
// Check if this controller has any Ajax_Endpoint methods
$api_methods = [];
foreach ($metadata['public_static_methods'] as $method_name => $method_info) {
if (!isset($method_info['attributes'])) {
continue;
}
// Check for Ajax_Endpoint attribute
$has_api_internal = false;
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
if ($attr_name === 'Ajax_Endpoint' ||
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
$has_api_internal = true;
break;
}
}
if ($has_api_internal && isset($method_info['static']) && $method_info['static']) {
$api_methods[$method_name] = [
'name' => $method_name,
];
}
}
// If no API methods, remove js_stub property if it exists and skip
if (empty($api_methods)) {
if (isset($metadata['js_stub'])) {
unset($metadata['js_stub']);
}
continue;
}
// Generate stub filename and paths
$controller_name = $metadata['class'];
$stub_filename = static::_sanitize_stub_filename($controller_name) . '.js';
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $stub_filename;
$stub_full_path = base_path($stub_relative_path);
// Check if stub needs regeneration
$needs_regeneration = true;
if (file_exists($stub_full_path)) {
// Get mtime of source PHP file
$source_mtime = $metadata['mtime'] ?? 0;
$stub_mtime = filemtime($stub_full_path);
// Only regenerate if source is newer than stub
if ($stub_mtime >= $source_mtime) {
// Also check if the API methods signature has changed
// by comparing a hash of the methods
$api_methods_hash = md5(json_encode($api_methods));
$old_api_hash = $metadata['api_methods_hash'] ?? '';
if ($api_methods_hash === $old_api_hash) {
$needs_regeneration = false;
}
}
}
// Store the API methods hash for future comparisons
$metadata['api_methods_hash'] = md5(json_encode($api_methods));
if ($needs_regeneration) {
// Generate stub content
$stub_content = static::_generate_stub_content($controller_name, $api_methods);
// Write stub file
file_put_contents($stub_full_path, $stub_content);
}
$generated_stubs[] = $stub_filename;
// Add js_stub property to manifest data (relative path)
$metadata['js_stub'] = $stub_relative_path;
// Add the stub file itself to the manifest
// This is critical because storage/rsx-build/js-stubs is not in scan directories
$stat = stat($stub_full_path);
static::$data['data']['files'][$stub_relative_path] = [
'file' => $stub_relative_path,
'hash' => sha1_file($stub_full_path),
'mtime' => $stat['mtime'],
'size' => $stat['size'],
'extension' => 'js',
'class' => $controller_name,
'is_stub' => true, // Mark this as a generated stub
'source_controller' => $file_path, // Reference to the source controller
];
}
// Clean up orphaned stub files (both from disk and manifest)
$existing_stubs = glob($stub_dir . '/*.js');
foreach ($existing_stubs as $existing_stub) {
$filename = basename($existing_stub);
if (!in_array($filename, $generated_stubs)) {
// Remove from disk
unlink($existing_stub);
// Remove from manifest
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $filename;
if (isset(static::$data['data']['files'][$stub_relative_path])) {
unset(static::$data['data']['files'][$stub_relative_path]);
}
}
}
}
// DEAD CODE REMOVED: _generate_js_api_stubs()
// Stub generation now handled by Controller_BundleIntegration
// ------------------------------------------------------------------------
/**
* Generate JavaScript stub files for ORM models
@@ -2093,34 +1957,10 @@ class Manifest
return strtolower(str_replace('_', '-', $controller_name));
}
/**
* Generate JavaScript stub content for a controller
*/
private static function _generate_stub_content(string $controller_name, array $api_methods): string
{
$content = "/**\n";
$content .= " * Auto-generated JavaScript stub for {$controller_name}\n";
$content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n";
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
$content .= " */\n\n";
$content .= "class {$controller_name} {\n";
foreach ($api_methods as $method_name => $method_info) {
$content .= " /**\n";
$content .= " * Call {$controller_name}::{$method_name} via Ajax.call()\n";
$content .= " * @param {...*} args - Arguments to pass to the method\n";
$content .= " * @returns {Promise<*>}\n";
$content .= " */\n";
$content .= " static async {$method_name}(...args) {\n";
$content .= " return Ajax.call('{$controller_name}', '{$method_name}', args);\n";
$content .= " }\n\n";
}
$content .= "}\n";
return $content;
}
// ------------------------------------------------------------------------
// DEAD CODE REMOVED: _generate_stub_content()
// Stub generation now handled by Controller_BundleIntegration
// ------------------------------------------------------------------------
/**
* Get or create the kernel instance
@@ -2936,6 +2776,7 @@ class Manifest
// Check if method calls parent::methodname()
// Use the correct file for trait methods
$source_file_to_read = $is_from_trait ? $method_file : $file_path;
try {
$method_source = file($source_file_to_read);
$start_line = $method->getStartLine() - 1;
@@ -3488,6 +3329,7 @@ class Manifest
$has_route = false;
$has_ajax_endpoint = false;
$has_task = false;
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) {
@@ -3496,17 +3338,26 @@ class Manifest
if ($attr_name === 'Ajax_Endpoint' || str_ends_with($attr_name, '\\Ajax_Endpoint')) {
$has_ajax_endpoint = true;
}
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
$has_task = true;
}
}
if ($has_route && $has_ajax_endpoint) {
// Check for conflicting attributes
$conflicts = [];
if ($has_route) $conflicts[] = 'Route';
if ($has_ajax_endpoint) $conflicts[] = 'Ajax_Endpoint';
if ($has_task) $conflicts[] = 'Task';
if (count($conflicts) > 1) {
$class_name = $metadata['class'] ?? 'Unknown';
throw new \RuntimeException(
"Controller action cannot have both Route and Ajax_Endpoint attributes.\n" .
"Method cannot have multiple execution type attributes: " . implode(', ', $conflicts) . "\n" .
"Class: {$class_name}\n" .
"Method: {$method_name}\n" .
"File: {$file_path}\n" .
'A controller action must be either a web route OR an AJAX endpoint, not both.'
'A method must be either a Route, Ajax_Endpoint, OR Task, not multiple types.'
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\RSpade\Core\Models;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
use App\RSpade\Core\Models\Region_Model;
/**
* RSX:USE
* Country_Model - ISO 3166-1 country data
*
* Represents countries with their ISO codes and names.
* Data populated from sokil/php-isocodes via rsx:seed:geographic-data command.
*/
class Country_Model extends Rsx_Model_Abstract
{
public static $enums = [];
protected $table = 'countries';
protected $casts = [
'enabled' => 'boolean',
];
/**
* Get all regions (subdivisions) for this country
*/
public function regions()
{
return $this->hasMany(Region_Model::class, 'country_alpha2', 'alpha2');
}
/**
* Scope to only enabled countries
*/
public function scopeEnabled($query)
{
return $query->where('enabled', true);
}
/**
* Get country by alpha2 code
*/
public static function findByAlpha2(string $alpha2): ?self
{
return static::where('alpha2', $alpha2)->first();
}
/**
* Get country by alpha3 code
*/
public static function findByAlpha3(string $alpha3): ?self
{
return static::where('alpha3', $alpha3)->first();
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\RSpade\Core\Models;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
use App\RSpade\Core\Models\Country_Model;
/**
* RSX:USE
* Region_Model - ISO 3166-2 subdivision data (states, provinces, territories)
*
* Represents geographic subdivisions with their ISO codes and names.
* Data populated from sokil/php-isocodes via rsx:seed:geographic-data command.
*/
class Region_Model extends Rsx_Model_Abstract
{
public static $enums = [];
protected $table = 'regions';
protected $casts = [
'enabled' => 'boolean',
];
/**
* Get the country this region belongs to
*/
public function country()
{
return $this->belongsTo(Country_Model::class, 'country_alpha2', 'alpha2');
}
/**
* Scope to only enabled regions
*/
public function scopeEnabled($query)
{
return $query->where('enabled', true);
}
/**
* Scope to regions for a specific country
*/
public function scopeForCountry($query, string $country_alpha2)
{
return $query->where('country_alpha2', $country_alpha2);
}
/**
* Get region by code
*/
public static function findByCode(string $code): ?self
{
return static::where('code', $code)->first();
}
}

View File

@@ -126,7 +126,8 @@ class Rsx_Bundle_Provider extends ServiceProvider
$blade->precompiler(function ($value) {
// Match @rsx_extends with optional second parameter for variables
// Pattern matches both @rsx_extends('id') and @rsx_extends('id', [...])
$pattern = '/@rsx_extends\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*(.+?))?\s*\)/';
// The 's' modifier allows . to match newlines for multiline arrays
$pattern = '/@rsx_extends\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*(.+?))?\s*\)/s';
return preg_replace_callback($pattern, function ($matches) {
$rsx_id = $matches[1];

View File

@@ -13,7 +13,6 @@ use App\Models\FlashAlert;
use RuntimeException;
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Rsx_Route_Proxy;
use App\RSpade\Core\Session\Session;
/**
@@ -192,63 +191,70 @@ HTML;
}
/**
* Create a route proxy for type-safe URL generation
* Generate URL for a controller route
*
* This method creates a route proxy that can generate URLs for a specific controller action.
* The proxy ensures all required route parameters are provided and handles extra parameters
* as query string values.
* This method generates URLs for controller actions by looking up route patterns
* and replacing parameters. It handles both regular routes and Ajax endpoints.
*
* Placeholder Routes:
* When the action starts with '#' (e.g., '#index', '#show'), it indicates a placeholder/unimplemented
* route for scaffolding purposes. These skip validation and return href="#" to allow incremental
* route for scaffolding purposes. These skip validation and return "#" to allow incremental
* development without requiring all controllers to exist.
*
* Usage examples:
* ```php
* // Simple route without parameters (defaults to 'index' action)
* $url = Rsx::Route('Frontend_Index_Controller')->url();
* $url = Rsx::Route('Frontend_Index_Controller');
* // Returns: /dashboard
*
* // Route with explicit action
* $url = Rsx::Route('Frontend_Index_Controller')->url();
* $url = Rsx::Route('Frontend_Index_Controller', 'index');
* // Returns: /dashboard
*
* // Route with required parameter
* $url = Rsx::Route('Frontend_Client_View_Controller')->url(['id' => 'C001']);
* // Route with integer parameter (sets 'id')
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', 123);
* // Returns: /clients/view/123
*
* // Route with named parameters (array)
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', ['id' => 'C001']);
* // Returns: /clients/view/C001
*
* // Route with required and query parameters
* $url = Rsx::Route('Frontend_Client_View_Controller')->url([
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', [
* 'id' => 'C001',
* 'tab' => 'history'
* ]);
* // Returns: /clients/view/C001?tab=history
*
* // Generate absolute URL
* $absolute = Rsx::Route('Frontend_Index_Controller')->absolute_url();
* // Returns: https://example.com/dashboard
*
* // Check if route is current
* if (Rsx::Route('Frontend_Index_Controller')->is_current()) {
* // This is the currently executing route
* }
*
* // Placeholder route for scaffolding (controller doesn't need to exist)
* $url = Rsx::Route('Future_Feature_Controller', '#index')->url();
* $url = Rsx::Route('Future_Feature_Controller', '#index');
* // Returns: #
* ```
*
* @param string $class_name The controller class name (e.g., 'User_Controller')
* @param string $action_name The action/method name (defaults to 'index'). Use '#action' for placeholders.
* @return Rsx_Route_Proxy Route proxy instance for URL generation
* @param int|array|\stdClass|null $params Route parameters. Integer sets 'id', array/object provides named params.
* @return string The generated URL
* @throws RuntimeException If class doesn't exist, isn't a controller, method doesn't exist, or lacks Route attribute
*/
public static function Route($class_name, $action_name = 'index')
public static function Route($class_name, $action_name = 'index', $params = null)
{
// Normalize params to array
$params_array = [];
if (is_int($params)) {
$params_array = ['id' => $params];
} elseif (is_array($params)) {
$params_array = $params;
} elseif ($params instanceof \stdClass) {
$params_array = (array) $params;
} elseif ($params !== null) {
throw new RuntimeException("Params must be integer, array, stdClass, or null");
}
// Placeholder route: action starts with # means unimplemented/scaffolding
// Skip all validation and return a placeholder proxy
// Skip all validation and return placeholder
if (str_starts_with($action_name, '#')) {
return new Rsx_Route_Proxy($class_name, $action_name, '#');
return '#';
}
// Try to find the class in the manifest
@@ -256,7 +262,7 @@ HTML;
$metadata = Manifest::php_get_metadata_by_class($class_name);
} catch (RuntimeException $e) {
// Report error at caller's location (the blade template or PHP code calling Rsx::Route)
throw new Rsx_Caller_Exception("Class {$class_name} not found in manifest");
throw new Rsx_Caller_Exception("Could not generate route URL: controller class {$class_name} not found");
}
// Verify it extends Rsx_Controller_Abstract
@@ -329,10 +335,14 @@ HTML;
}
}
// If has Ajax_Endpoint, return AJAX route URL
// If has Ajax_Endpoint, return AJAX route URL (no param substitution)
if ($has_ajax_endpoint) {
$ajax_url = '/_ajax/' . urlencode($class_name) . '/' . urlencode($action_name);
return new Rsx_Route_Proxy($class_name, $action_name, $ajax_url);
// Add query params if provided
if (!empty($params_array)) {
$ajax_url .= '?' . http_build_query($params_array);
}
return $ajax_url;
}
if (!$has_route) {
@@ -343,6 +353,68 @@ HTML;
throw new Rsx_Caller_Exception("Route attribute on {$class_name}::{$action_name} must have a pattern");
}
return new Rsx_Route_Proxy($class_name, $action_name, $route_pattern);
// Generate URL from pattern
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, $action_name);
}
/**
* Generate URL from route pattern by replacing parameters
*
* @param string $pattern The route pattern (e.g., '/users/:id/view')
* @param array $params Parameters to fill into the route
* @param string $class_name Controller class name (for error messages)
* @param string $action_name Action name (for error messages)
* @return string The generated URL
* @throws RuntimeException If required parameters are missing
*/
protected static function _generate_url_from_pattern($pattern, $params, $class_name, $action_name)
{
// Extract required parameters from the pattern
$required_params = [];
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
$required_params = $matches[1];
}
// Check for required parameters
$missing = [];
foreach ($required_params as $required) {
if (!array_key_exists($required, $params)) {
$missing[] = $required;
}
}
if (!empty($missing)) {
throw new RuntimeException(
"Required parameters [" . implode(', ', $missing) . "] are missing for route " .
"{$pattern} on {$class_name}::{$action_name}"
);
}
// Build the URL by replacing parameters
$url = $pattern;
$used_params = [];
foreach ($required_params as $param_name) {
$value = $params[$param_name];
// URL encode the value
$encoded_value = urlencode($value);
$url = str_replace(':' . $param_name, $encoded_value, $url);
$used_params[$param_name] = true;
}
// Collect any extra parameters for query string
$query_params = [];
foreach ($params as $key => $value) {
if (!isset($used_params[$key])) {
$query_params[$key] = $value;
}
}
// Append query string if there are extra parameters
if (!empty($query_params)) {
$url .= '?' . http_build_query($query_params);
}
return $url;
}
}

View File

@@ -1,199 +0,0 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core;
use RuntimeException;
/**
* Rsx_Route_Proxy - Type-safe route URL generator
*
* This class provides methods for generating URLs from route patterns,
* ensuring all required parameters are provided and handling extra
* parameters as query strings.
*/
#[Instantiatable]
class Rsx_Route_Proxy
{
/**
* @var string The controller class name
*/
protected string $_class;
/**
* @var string The action/method name
*/
protected string $_method;
/**
* @var string The route pattern (e.g., '/users/:id/:action')
*/
protected string $_pattern;
/**
* @var array Required parameters extracted from the pattern
*/
protected array $_required_params = [];
/**
* Constructor
*
* @param string $class_name The controller class name
* @param string $method_name The action/method name
* @param string $pattern The route pattern
*/
public function __construct(string $class_name, string $method_name, string $pattern)
{
$this->_class = $class_name;
$this->_method = $method_name;
$this->_pattern = $pattern;
// Extract required parameters from the pattern
$this->_extract_required_params();
}
/**
* Extract required parameters from the route pattern
*
* @return void
*/
protected function _extract_required_params(): void
{
// Match all :param patterns in the route
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $this->_pattern, $matches)) {
$this->_required_params = $matches[1];
}
}
/**
* Check if this route matches the current controller and action
*
* @return bool True if this is the current route
*/
public function is_current(): bool
{
$current_controller = \App\RSpade\Core\Rsx::get_current_controller();
$current_action = \App\RSpade\Core\Rsx::get_current_action();
return $current_controller === $this->_class &&
$current_action === $this->_method;
}
/**
* Generate a relative URL for the route
*
* @param array $params Parameters to fill into the route
* @return string The generated URL
* @throws RuntimeException If required parameters are missing
*/
public function url(array $params = []): string
{
// Check if the method name starts with '#' - indicates unimplemented route
if (str_starts_with($this->_method, '#')) {
return '#';
}
// Check for required parameters
$missing = [];
foreach ($this->_required_params as $required) {
if (!array_key_exists($required, $params)) {
$missing[] = $required;
}
}
if (!empty($missing)) {
throw new RuntimeException(
"Required parameters [" . implode(', ', $missing) . "] are missing for route " .
"{$this->_pattern} on {$this->_class}::{$this->_method}"
);
}
// Build the URL by replacing parameters
$url = $this->_pattern;
$used_params = [];
foreach ($this->_required_params as $param_name) {
$value = $params[$param_name];
// URL encode the value
$encoded_value = urlencode($value);
$url = str_replace(':' . $param_name, $encoded_value, $url);
$used_params[$param_name] = true;
}
// Collect any extra parameters for query string
$query_params = [];
foreach ($params as $key => $value) {
if (!isset($used_params[$key])) {
$query_params[$key] = $value;
}
}
// Append query string if there are extra parameters
if (!empty($query_params)) {
$url .= '?' . http_build_query($query_params);
}
return $url;
}
/**
* Generate an absolute URL for the route
*
* @param array $params Parameters to fill into the route
* @return string The generated absolute URL
* @throws RuntimeException If required parameters are missing
*/
public function absolute_url(array $params = []): string
{
// Check if the method name starts with '#' - indicates unimplemented route
if (str_starts_with($this->_method, '#')) {
return '#';
}
// Get the relative URL first
$relative_url = $this->url($params);
// Get the current request to extract protocol and domain
$request = request();
// Build the absolute URL
$protocol = $request->secure() ? 'https' : 'http';
$host = $request->getHost();
$port = $request->getPort();
// Only include port if it's non-standard
$port_suffix = '';
if (($protocol === 'http' && $port != 80) || ($protocol === 'https' && $port != 443)) {
$port_suffix = ':' . $port;
}
return $protocol . '://' . $host . $port_suffix . $relative_url;
}
/**
* Navigate to the route by sending a redirect header
*
* @param array $params Parameters to fill into the route
* @return never
* @throws RuntimeException If required parameters are missing
*/
public function navigate(array $params = []): never
{
// Check if the method name starts with '#' - indicates unimplemented route
if (str_starts_with($this->_method, '#')) {
throw new RuntimeException(
"Cannot navigate to unimplemented route: {$this->_class}::{$this->_method}"
);
}
$url = $this->url($params);
// Send redirect header
header('Location: ' . $url, true, 302);
exit(0);
}
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Service;
/**
* Base service class for all RSX services
*
* Services house background tasks that can be executed via CLI, queued, or scheduled.
* All RSX services should extend this class and use standard OOP patterns.
* Tasks are defined using #[Task] attributes on static methods.
*/
#[Monoprogenic]
abstract class Rsx_Service_Abstract
{
/**
* Pre-task hook called before any task execution
* Override in child classes to add pre-task logic
*
* @param array $params Task parameters
* @return mixed|null Return null to continue, or throw exception to halt
*/
public static function pre_task(array $params = [])
{
// Default implementation does nothing
// Override in child classes to add authentication, validation, logging, etc.
return null;
}
/**
* Get a parameter value with optional default
*
* @param array $params Parameters array
* @param string $key Parameter key
* @param mixed $default Default value if not found
* @return mixed
*/
protected static function __param($params, $key, $default = null)
{
return $params[$key] ?? $default;
}
/**
* Check if a parameter exists
*
* @param array $params Parameters array
* @param string $key Parameter key
* @return bool
*/
protected static function __has_param($params, $key)
{
return isset($params[$key]);
}
}

128
app/RSpade/Core/Task/Task.php Executable file
View File

@@ -0,0 +1,128 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Task;
use Exception;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
/**
* Task - Unified task execution system
*
* Handles background task execution:
* - Internal PHP task calls (internal method)
* - Future: Queue integration, scheduling, progress tracking
*/
class Task
{
/**
* Execute a task internally from PHP code
*
* Used for server-side code to invoke tasks without CLI overhead.
* This is useful for calling tasks from other tasks, background jobs, etc.
*
* @param string $rsx_service Service name (e.g., 'Seeder_Service')
* @param string $rsx_task Task/method name (e.g., 'seed_clients')
* @param array $params Parameters to pass to the task
* @return mixed The response from the task method
* @throws Exception
*/
public static function internal($rsx_service, $rsx_task, $params = [])
{
// Get manifest to find service
$manifest = Manifest::get_all();
$service_class = null;
$file_info = null;
// Search for service class in manifest
foreach ($manifest as $file_path => $info) {
// Skip non-PHP files or files without classes
if (!isset($info['class']) || !isset($info['fqcn'])) {
continue;
}
// Check if class name matches exactly (without namespace)
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
if ($class_basename === $rsx_service) {
$service_class = $info['fqcn'];
$file_info = $info;
break;
}
}
if (!$service_class) {
throw new Exception("Service class not found: {$rsx_service}");
}
// Check if class exists
if (!class_exists($service_class)) {
throw new Exception("Service class does not exist: {$service_class}");
}
// Check if it's a subclass of Rsx_Service_Abstract
if (!Manifest::php_is_subclass_of($service_class, Rsx_Service_Abstract::class)) {
throw new Exception("Service {$service_class} must extend Rsx_Service_Abstract");
}
// Check if method exists and has Task attribute
if (!isset($file_info['public_static_methods'][$rsx_task])) {
throw new Exception("Task {$rsx_task} not found in service {$service_class}");
}
$method_info = $file_info['public_static_methods'][$rsx_task];
$has_task = false;
// Check for Task attribute in method metadata
if (isset($method_info['attributes'])) {
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
$has_task = true;
break;
}
}
}
if (!$has_task) {
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
}
// Call pre_task() if exists
if (method_exists($service_class, 'pre_task')) {
$pre_result = $service_class::pre_task($params);
if ($pre_result !== null) {
// pre_task returned something, use that as response
return $pre_result;
}
}
// Call the actual task method
$response = $service_class::$rsx_task($params);
// Filter response through JSON encode/decode to remove PHP objects
// (similar to Ajax behavior)
$filtered_response = json_decode(json_encode($response), true);
return $filtered_response;
}
/**
* Format task response for CLI output
* Wraps the response in a consistent format
*
* @param mixed $response Task return value
* @return array Formatted response
*/
public static function format_task_response($response): array
{
return [
'success' => true,
'result' => $response,
];
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\RSpade\Core\Testing;
use Exception;
use Illuminate\Http\Request;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
/**
* RSX:USE
* Rsx_Formdata_Generator_Controller - Generate random test data for form fields
*
* Provides Ajax endpoints that return randomized test data for form seeding.
* All methods fatal error in production mode - this is strictly a development tool.
*
* Usage from widgets:
* let value = await Rsx_Formdata_Generator_Controller.first_name();
* let email = await Rsx_Formdata_Generator_Controller.email();
*/
class Rsx_Formdata_Generator_Controller extends Rsx_Controller_Abstract
{
private static $first_names = null;
private static $last_names = null;
private static $cities = null;
private static $street_names = null;
private static $words = null;
/**
* Check if running in production and throw if true
*/
private static function __check_production(): void
{
if (app()->environment() === 'production') {
throw new Exception('Rsx_Formdata_Generator_Controller cannot be used in production environment');
}
}
/**
* Load word list from resource file
*/
private static function __load_wordlist(string $filename): array
{
$path = __DIR__ . '/resource/' . $filename;
if (!file_exists($path)) {
throw new Exception("Word list not found: {$filename}");
}
$content = file_get_contents($path);
$lines = explode("\n", trim($content));
return array_filter($lines, fn($line) => !empty(trim($line)));
}
/**
* Get random item from word list
*/
private static function __random_from_list(string $list_name): string
{
if (self::${$list_name} === null) {
self::${$list_name} = self::__load_wordlist($list_name . '.txt');
}
$list = self::${$list_name};
return $list[array_rand($list)];
}
/**
* Generate random first name
*/
#[Ajax_Endpoint]
public static function first_name(Request $request, array $params = []): string
{
self::__check_production();
return self::__random_from_list('first_names');
}
/**
* Generate random last name
*/
#[Ajax_Endpoint]
public static function last_name(Request $request, array $params = []): string
{
self::__check_production();
return self::__random_from_list('last_names');
}
/**
* Generate random company name
*/
#[Ajax_Endpoint]
public static function company_name(Request $request, array $params = []): string
{
self::__check_production();
$patterns = [
// Word + Industries/Solutions/Systems/Technologies
fn() => self::__random_from_list('words') . ' ' . ['Industries', 'Solutions', 'Systems', 'Technologies', 'Corporation', 'Group', 'Enterprises'][rand(0, 6)],
// LastName + FirstName + LLC
fn() => self::__random_from_list('last_names') . ' ' . self::__random_from_list('first_names') . ' LLC',
// Word + Word + Inc
fn() => ucfirst(self::__random_from_list('words')) . ' ' . ucfirst(self::__random_from_list('words')) . ' Inc',
// City + Industries
fn() => self::__random_from_list('cities') . ' ' . ['Consulting', 'Services', 'Partners', 'Associates', 'Ventures'][rand(0, 4)],
];
return $patterns[array_rand($patterns)]();
}
/**
* Generate random street address
*/
#[Ajax_Endpoint]
public static function address(Request $request, array $params = []): string
{
self::__check_production();
$number = rand(1, 9999);
$street = self::__random_from_list('street_names');
$suffix = ['Street', 'Avenue', 'Road', 'Drive', 'Lane', 'Way', 'Boulevard', 'Court', 'Place'][rand(0, 8)];
return "{$number} {$street} {$suffix}";
}
/**
* Generate random city name
*/
#[Ajax_Endpoint]
public static function city(Request $request, array $params = []): string
{
self::__check_production();
return self::__random_from_list('cities');
}
/**
* Generate random US state code
*/
#[Ajax_Endpoint]
public static function state(Request $request, array $params = []): string
{
self::__check_production();
$states = [
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
];
return $states[array_rand($states)];
}
/**
* Generate random ZIP code
*/
#[Ajax_Endpoint]
public static function zip(Request $request, array $params = []): string
{
self::__check_production();
return str_pad((string)rand(10000, 99999), 5, '0', STR_PAD_LEFT);
}
/**
* Generate random phone number in 555-XXX-XXXX format
* Avoids 555-000-0000 and any sequence containing 911
*/
#[Ajax_Endpoint]
public static function phone(Request $request, array $params = []): string
{
self::__check_production();
$attempts = 0;
do {
$middle = str_pad((string)rand(100, 999), 3, '0', STR_PAD_LEFT);
$last = str_pad((string)rand(1, 9999), 4, '0', STR_PAD_LEFT);
$phone = "555-{$middle}-{$last}";
$has_911 = strpos($phone, '911') !== false;
$is_all_zeros = $phone === '555-000-0000';
$attempts++;
if ($attempts > 100) {
// Return safe number after max attempts
return '555-' . rand(100, 899) . '-' . rand(1000, 9999);
}
} while ($has_911 || $is_all_zeros);
return $phone;
}
/**
* Generate random email address
* Format: wordwordnumbers+test@gmail.com
*/
#[Ajax_Endpoint]
public static function email(Request $request, array $params = []): string
{
self::__check_production();
$word1 = self::__random_from_list('words');
$word2 = self::__random_from_list('words');
$numbers = rand(100, 9999);
return "{$word1}{$word2}{$numbers}+test@gmail.com";
}
/**
* Generate random website URL
*/
#[Ajax_Endpoint]
public static function website(Request $request, array $params = []): string
{
self::__check_production();
$word1 = self::__random_from_list('words');
$word2 = self::__random_from_list('words');
$tld = ['com', 'net', 'org', 'io', 'co'][rand(0, 4)];
return "https://{$word1}{$word2}.{$tld}";
}
/**
* Generate random text/paragraph
*/
#[Ajax_Endpoint]
public static function text(Request $request, array $params = []): string
{
self::__check_production();
$sentence_count = rand(3, 6);
$sentences = [];
for ($i = 0; $i < $sentence_count; $i++) {
$word_count = rand(5, 12);
$words = [];
for ($j = 0; $j < $word_count; $j++) {
$words[] = self::__random_from_list('words');
}
$sentence = ucfirst(implode(' ', $words)) . '.';
$sentences[] = $sentence;
}
return implode(' ', $sentences);
}
}

View File

@@ -0,0 +1,4 @@
[2025-10-30 05:42:23] rsx:refactor:rename_php_class Rsx_Formdata_Generator Rsx_Formdata_Generator_Controller
[2025-10-30 06:19:35] rsx:refactor:rename_php_class_function Rsx_Formdata_Generator_Controller check_production __check_production
[2025-10-30 06:19:37] rsx:refactor:rename_php_class_function Rsx_Formdata_Generator_Controller load_wordlist __load_wordlist
[2025-10-30 06:19:39] rsx:refactor:rename_php_class_function Rsx_Formdata_Generator_Controller random_from_list __random_from_list

View File

@@ -0,0 +1,218 @@
Springfield
Madison
Georgetown
Franklin
Clinton
Arlington
Ashland
Burlington
Chester
Clayton
Dover
Dayton
Easton
Fairview
Georgetown
Hamilton
Hudson
Jackson
Kingston
Lancaster
Lincoln
Marion
Manchester
Newport
Oxford
Portland
Princeton
Richmond
Salem
Shelby
Somerset
Vernon
Waverly
Webster
Westfield
Wilmington
Winston
Ashford
Brighton
Cambridge
Danville
Eastwood
Fairmont
Glendale
Hillside
Ivywood
Jamestown
Kirkland
Lakewood
Midland
Newtown
Oakland
Parkside
Redwood
Southgate
Thornton
Unionville
Valley
Woodland
Alton
Bedford
Carver
Dalton
Elgin
Fletcher
Garfield
Hartwell
Inglewood
Jefferson
Kensington
Lexington
Morton
Newton
Oakdale
Preston
Quincy
Riverside
Stanford
Trenton
Upton
Vinton
Walton
Yardley
Zenith
Auburn
Bristol
Carlton
Denton
Elmwood
Fairfield
Greenwood
Hartfield
Ironwood
Jasper
Kenwood
Linwood
Maple
Norfolk
Oakwood
Pinewood
Riverdale
Springdale
Thorndale
Upland
Vista
Westwood
Yarmouth
Zenith
Ashton
Barton
Clifton
Dixon
Eaton
Fulton
Grafton
Horton
Ironton
Junction
Keaton
Lawton
Melton
Norton
Orton
Paxton
Ralston
Seaton
Tilton
Upton
Walton
Yeaton
Zephyr
Beacon
Canyon
Delta
Echo
Forest
Grove
Harbor
Island
Jordan
Knoll
Lagoon
Meadow
North
Ocean
Plains
Ridge
Summit
Timber
Union
Valley
Waters
Zenith
Alpine
Bluff
Crest
Desert
Eagle
Falls
Glen
Haven
Inlet
Juniper
Knob
Lake
Mesa
North
Oaks
Park
Ranch
Shore
Trail
Union
Vista
Woods
Zenith
Anchor
Basin
Cape
Dune
Edge
Ford
Gulf
Hill
Isle
Key
Lagoon
Mount
North
Outlet
Point
River
Sound
Tides
Union
View
West
Zephyr
Atlas
Brook
Creek
Dale
East
Field
Glade
Heath
Knoll
Lawn
Marsh
North
Pond
Range
South
Trace
Vale
West
Yard
Zone

View File

@@ -0,0 +1,240 @@
Alex
Blake
Casey
Dakota
Ellis
Finley
Gray
Harper
Indigo
Jordan
Kai
Logan
Morgan
Noel
Owen
Parker
Quinn
Reese
Sage
Taylor
Adrian
Blake
Cameron
Dylan
Emerson
Flynn
Grayson
Hayden
Ivan
Jasper
Kyle
Lane
Mason
Nathan
Oliver
Phoenix
Quincy
River
Shane
Tyler
Aiden
Bella
Carter
Devin
Ethan
Fiona
Gabriel
Hazel
Isaac
Julia
Kevin
Luna
Miles
Nina
Oscar
Piper
Quinn
Ruby
Sam
Tessa
Uma
Victor
Wyatt
Xander
Yara
Zane
Ash
Bay
Cole
Drew
Eden
Fox
Gage
Hale
Iris
Jay
Knox
Lake
Max
Nova
Onyx
Penn
Rain
Sky
Tate
Vale
West
York
Zara
Ace
Bay
Cruz
Dean
Echo
Faye
Glen
Hart
Isla
Jude
Kent
Leaf
Milo
Nash
Opal
Pine
Reed
Star
Theo
Vale
West
Zeke
Alba
Beau
Cleo
Dale
Eden
Fern
Glen
Hope
Ines
June
Kent
Lake
Moss
Nora
Odin
Pine
Rose
Snow
Tide
Vale
Wolf
Zinc
Alma
Beck
Clay
Dawn
East
Ford
Glen
Hope
Jade
Kale
Lane
Moss
Nash
Opal
Page
Reed
Sage
Tide
Vale
Wade
Zane
Alto
Bear
Cove
Dawn
Edge
Finn
Glen
Hill
Isle
June
Lake
Moon
Navy
Peak
Rain
Sand
Tide
Vale
Wave
Zone
Arch
Beam
Cliff
Dell
East
Ford
Glen
Hill
Isle
Kent
Lake
Mill
North
Peak
Reed
South
Tide
Vale
West
Zinc
Alma
Beck
Cove
Dawn
Eden
Fern
Glen
Hope
Isle
Jade
Kent
Lake
Moss
Nora
Oak
Pine
Reed
Sage
Tide
Vale
Wade
York
Zane
Alto
Bay
Cove
Dale
Eden
Ford
Glen
Hope
Isle
June
Kent
Lane
Moon
North
Oak
Peak
Reed
Sky
Tide
Vale
West
York
Zone

View File

@@ -0,0 +1,219 @@
Anderson
Baker
Carter
Davis
Edwards
Foster
Garcia
Harris
Jackson
Kennedy
Lopez
Martinez
Nelson
Oliver
Parker
Quinn
Roberts
Smith
Thompson
Turner
Adams
Bennett
Campbell
Dixon
Ellis
Franklin
Gray
Hansen
Ingram
Jenkins
King
Lewis
Mitchell
Norton
Owen
Palmer
Reed
Sanders
Taylor
Vincent
Walker
Young
Allen
Brown
Clarke
Douglas
Evans
Fisher
Green
Hill
Irving
Jones
Kelly
Long
Morgan
Nash
Olson
Powell
Rice
Scott
Tucker
Wagner
White
Zimmerman
Abbott
Bishop
Chase
Drake
Elliott
Flynn
Grant
Hayes
Irwin
James
Knox
Lane
Mason
Noble
Orton
Pierce
Rhodes
Stone
Torres
Valdez
Warren
Xavier
York
Zeller
Archer
Bryant
Cohen
Dean
Ellis
Ford
Griffin
Hughes
Ingram
Jordan
Klein
Lynch
Murphy
Nash
Osborne
Palmer
Quinn
Russell
Shaw
Turner
Vaughn
Wells
York
Zimmerman
Avery
Brandt
Cross
Dale
Easton
Fields
Graham
Hale
Irons
Joyce
Kemp
Lane
Marsh
Noble
Owens
Parks
Quinn
Rowe
Shaw
Todd
Vale
West
York
Zeller
Atlas
Blake
Crane
Drake
Flint
Grove
Holt
Knox
Lowe
Mills
North
Page
Ridge
Shaw
Todd
Vale
Wade
York
Zinc
Ash
Bay
Cole
Dale
Ford
Glen
Hill
Kent
Lake
Moon
Nash
Oak
Peak
Reed
Sky
Tide
Vale
West
York
Zinc
Arch
Beck
Cove
Dale
Eden
Fern
Glen
Hope
Isle
June
Kent
Lake
Moss
Nash
Oak
Pine
Reed
Sage
Tide
Vale
Wade
York
Zone
Beam
Cliff
Dell
East
Ford
Glen
Hill
Isle
Kent
Lake
Mill
North
Oak
Peak
Reed
South
Tide
Vale
West
York
Zone

View File

@@ -0,0 +1,210 @@
Main
Oak
Maple
Pine
Cedar
Elm
Washington
Park
Lake
Hill
River
Spring
Forest
Meadow
Valley
Ridge
Summit
Grove
Garden
Church
School
College
Union
Liberty
Franklin
Lincoln
Madison
Jefferson
Adams
Jackson
Harrison
Wilson
Taylor
Grant
Hayes
Garfield
Cleveland
Roosevelt
Truman
Eisenhower
Kennedy
Johnson
Nixon
Ford
Carter
Reagan
Bush
Clinton
Market
Water
Bridge
Mill
Station
Factory
Commerce
Industrial
Center
Plaza
Square
Circle
Court
Place
Avenue
Street
Road
Drive
Lane
Way
Path
Trail
Route
Highway
Parkway
Boulevard
Terrace
View
Heights
Point
Beach
Shore
Bay
Harbor
Island
Cape
Inlet
Cove
Creek
Brook
Stream
Pond
Falls
Canyon
Valley
Mountain
Hill
Ridge
Peak
Summit
Bluff
Cliff
Mesa
Prairie
Plains
Meadow
Field
Garden
Orchard
Grove
Woods
Forest
Glen
Dell
Hollow
Knoll
Slope
Hillside
Riverside
Lakeside
Seaside
Oceanside
Bayside
Harborside
Creekside
Brookside
Streamside
Pondside
Woodland
Parkland
Highland
Lowland
Midland
Upland
Grassland
Farmland
Ranchland
Timberland
Wetland
North
South
East
West
Central
Middle
Upper
Lower
New
Old
Grand
Royal
Crown
Kings
Queens
Prince
Duke
Earl
Lord
Manor
Estate
Villa
Palace
Castle
Tower
Bridge
Gate
Arch
Portal
Square
Circle
Triangle
Diamond
Star
Moon
Sun
Dawn
Dusk
Sunrise
Sunset
Morning
Evening
Twilight
Midnight
Noon
Spring
Summer
Autumn
Winter
January
February
March
April
May
June
July
August
September
October
November
December
First
Second
Third
Fourth
Fifth
Sixth
Seventh
Eighth
Ninth
Tenth
Eleventh
Twelfth

View File

@@ -0,0 +1,174 @@
alpha
beta
gamma
delta
echo
foxtrot
golf
hotel
india
juliet
kilo
lima
mike
november
oscar
papa
quebec
romeo
sierra
tango
uniform
victor
whiskey
xray
yankee
zulu
amber
azure
bronze
cobalt
crimson
emerald
gold
ivory
jade
khaki
lavender
magenta
navy
olive
pearl
ruby
silver
teal
violet
wheat
yellow
zinc
apple
banana
cherry
date
fig
grape
kiwi
lemon
mango
olive
peach
plum
quince
raisin
tangerine
walnut
acorn
birch
cedar
daisy
fern
hazel
ivy
juniper
lotus
maple
nettle
orchid
poppy
rose
sage
tulip
willow
atlas
beacon
cipher
delta
enigma
falcon
harbor
icarus
jaguar
keystone
lynx
matrix
nexus
oracle
phoenix
quantum
raven
solar
titan
ultra
vertex
zenith
anchor
blade
crest
drift
ember
forge
ghost
haven
iron
jade
knight
lunar
mirror
noble
omega
prism
quest
rogue
storm
thunder
unity
valor
wave
xenon
zodiac
arch
bolt
chord
dawn
edge
flare
glow
halo
index
jewel
knot
light
mesh
node
orbit
pulse
quark
ray
spark
trace
unity
vortex
warp
zone
atom
beam
core
dome
flux
grid
helix
ion
jet
kinetic
loop
mass
neuron
optic
phase
quasar
radar
scope
tempo
ultra
vector
watt

View File

@@ -308,8 +308,23 @@ class JqhtmlBladeCompiler
// 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];
// Check if value contains Blade expressions {{ }} or {!! !!}
// These need to be extracted and treated as PHP expressions
if (is_string($value) && preg_match('/^(\{\{|\{!!)\s*(.+?)\s*(\}\}|!!})$/s', $value, $blade_match)) {
// Extract the PHP expression from inside the Blade braces
$php_expression = $blade_match[2];
// For {{ }} (escaped output), wrap in e() helper
// For {!! !!} (raw output), use as-is
if ($blade_match[1] === '{{') {
$php_expression = "e({$php_expression})";
}
$parsed[$key] = ['type' => 'expression', 'value' => $php_expression];
} else {
// Regular string value
$parsed[$key] = ['type' => 'string', 'value' => $value];
}
}
}

View File

@@ -39,6 +39,12 @@ class Jqhtml_Integration {
components_needing_init.each(function () {
const $element = $(this);
// Skip if element is no longer attached to the document
// (may have been removed by a parent component's .empty() call)
if (!document.contains($element[0])) {
return;
}
// Check if any parent has Jqhtml_Component_Init class - skip nested components
let parent = $element[0].parentElement;
while (parent) {
@@ -100,7 +106,7 @@ class Jqhtml_Integration {
// Get the updated $element from
let component = $element.component(component_name, component_args_filtered);
component.on('ready', function () {
component.on('render', 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

View File

@@ -273,7 +273,7 @@ CALLING API METHODS
ROUTE RESOLUTION
PHP:
$url = Rsx::Route('User_Controller', 'show')->url(['id' => 5]);
$url = Rsx::Route('User_Controller', 'show', ['id' => 5]);
// Returns: "/users/5"
if (Rsx::Route('User_Controller')->is_current()) {

View File

@@ -0,0 +1,582 @@
FORMS_AND_WIDGETS(3) RSX Framework Manual FORMS_AND_WIDGETS(3)
NAME
Forms and Widgets - RSX form system with reusable widget components
SYNOPSIS
// Blade form markup
<Rsx_Form $data="{{ json_encode($form_data) }}"
$action="{{ Rsx::Route('Controller', 'save') }}">
<Form_Field $name="email" $label="Email Address" $required=true>
<Text_Input $type="email" $placeholder="user@example.com" />
</Form_Field>
<Form_Field $name="bio" $label="Biography">
<Text_Input $type="textarea" $rows=5 />
</Form_Field>
<button type="button" id="save-btn">Save</button>
</Rsx_Form>
// JavaScript - wire save button to form
$('#save-btn').on('click', function() {
const $form = $('.Rsx_Form').first();
$form.component().submit();
});
DESCRIPTION
The RSX form system provides a clean separation between form structure
(Rsx_Form), field layout (Form_Field), and input widgets (Text_Input,
Select_Input, etc). This architecture enables:
- Reusable widgets across all forms
- Consistent validation error display
- Automatic value collection and population
- Test data generation via seeders
- Read-only/disabled states
- Custom field layouts without modifying widget code
Key Components:
- Rsx_Form: Container managing form submission and validation
- Form_Field: Layout wrapper providing labels, help text, error display
- Widgets: Reusable input components (Text_Input, Select_Input, etc)
RSX_FORM COMPONENT
The Rsx_Form component manages form data flow, submission, and validation.
Required Attributes:
$action - Controller method reference for form submission
Example: Frontend_Clients_Controller.save
Optional Attributes:
$data - JSON-encoded object with initial form values
Used for edit mode to populate fields
Example: $data="{{ json_encode($client_data) }}"
Methods:
vals() - Get all form values as object
vals(values) - Set all form values from object
submit() - Submit form to $action endpoint
seed() - Fill all fields with test data (debug mode only)
Form Discovery:
Rsx_Form automatically discovers all widgets using shallowFind('.Widget')
and collects values based on their data-name attributes. No registration
or manual wiring required.
Example - Basic Form:
<Rsx_Form $action="{{ Rsx::Route('Users_Controller', 'save') }}">
<Form_Field $name="first_name" $label="First Name">
<Text_Input />
</Form_Field>
<Form_Field $name="last_name" $label="Last Name">
<Text_Input />
</Form_Field>
<button type="button" id="save-btn">Save</button>
</Rsx_Form>
Example - Edit Mode with Initial Data:
@php
$form_data = [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'email' => $user->email,
];
@endphp
<Rsx_Form $data="{{ json_encode($form_data) }}"
$action="{{ Rsx::Route('Users_Controller', 'save') }}">
<!-- Fields automatically populated from $data -->
</Rsx_Form>
FORM_FIELD WRAPPER
Form_Field provides consistent layout for labels, help text, and error
display. It wraps a single widget and connects it to the form.
Required Attributes:
$name - Field name for form serialization and error display
Optional Attributes:
$label - Label text displayed above field
$required - Boolean, adds red asterisk to label
$help - Help text displayed below field
Responsibilities:
- Display label with optional required indicator
- Set data-name attribute on child widget
- Display validation errors returned from server
- Provide consistent spacing and styling
Example - Basic Field:
<Form_Field $name="email" $label="Email Address">
<Text_Input $type="email" />
</Form_Field>
Example - Required Field with Help Text:
<Form_Field $name="password"
$label="Password"
$required=true
$help="Must be at least 8 characters">
<Text_Input $type="password" />
</Form_Field>
Example - Field with HTML in Label:
<Form_Field $name="twitter" $label="<i class='bi bi-twitter'></i> Twitter">
<Text_Input $prefix="@" />
</Form_Field>
Custom Layout:
Form_Field can be extended or replaced with custom jqhtml to change
field layout. The only requirement is that the child widget must
have the data-name attribute set to the field name.
Example - Horizontal Layout:
<Define:Form_Field_Horizontal extends="Form_Field">
<div class="row mb-3">
<label class="col-md-3 col-form-label">
<%!= this.args.label %>
</label>
<div class="col-md-9">
<%= content() %>
<% if (this.has_error()) { %>
<div class="invalid-feedback d-block">
<%= this.get_error() %>
</div>
<% } %>
</div>
</div>
</Define:Form_Field_Horizontal>
WIDGET INTERFACE
All form widgets must implement the standard widget interface:
Required:
- CSS class "Widget" on root element
- val() method for getting current value
- val(value) method for setting value
Optional:
- seed() method for generating test data
- Support for $disabled attribute
Widget Responsibilities:
1. Value Management
Widgets must implement getter/setter via val() method:
val() {
// Getter - return current value
if (arguments.length === 0) {
return this.$id('input').val();
}
// Setter - update value
else {
this.data.value = value || '';
this.$id('input').val(this.data.value);
}
}
2. Disabled State
Widgets should respect $disabled attribute:
- Render with disabled HTML attribute
- Display grayed-out appearance
- Still return value via val() getter
- Do not submit in HTML form (handled by browser)
3. Test Data (Optional)
Widgets may implement seed() for debug mode:
async seed() {
if (this.args.seeder) {
// Generate test data
this.val('Test Value');
}
}
BUILT-IN WIDGETS
Text_Input
Basic text input supporting multiple types and textarea.
Attributes:
$type - Input type (text, email, url, tel, number, textarea)
$rows - Number of rows for textarea (default: 3)
$placeholder - Placeholder text
$prefix - Text to prepend (creates input-group)
$suffix - Text to append (creates input-group)
$min - Minimum value for number inputs
$max - Maximum value for number inputs
$maxlength - Maximum length for text inputs
$disabled - Disable input (grayed out, still returns value)
$seeder - Seeder function name for test data
Examples:
<Text_Input $type="email" $placeholder="user@example.com" />
<Text_Input $type="textarea" $rows=5 $placeholder="Enter bio..." />
<Text_Input $type="number" $min=0 $max=100 />
<Text_Input $prefix="@" $placeholder="username" />
<Text_Input $type="url" $disabled=true />
Select_Input
Dropdown select with options.
Attributes:
$options - Array of options (see below)
$placeholder - Placeholder option text
$disabled - Disable select (grayed out, still returns value)
$seeder - Seeder function name for test data
Options Format:
Simple array: ['Option 1', 'Option 2', 'Option 3']
Object array: [
{value: 'opt1', label: 'Option 1'},
{value: 'opt2', label: 'Option 2'}
]
From Blade: $options="{{ json_encode($options_array) }}"
Examples:
@php
$industries = ['Technology', 'Finance', 'Healthcare'];
@endphp
<Select_Input $options="{{ json_encode($industries) }}"
$placeholder="Select Industry..." />
@php
$sizes = [
['value' => 'sm', 'label' => 'Small (1-10)'],
['value' => 'md', 'label' => 'Medium (11-50)'],
['value' => 'lg', 'label' => 'Large (50+)'],
];
@endphp
<Select_Input $options="{{ json_encode($sizes) }}" />
Checkbox_Input
Checkbox with optional label.
Attributes:
$label - Label text displayed next to checkbox
$checked_value - Value when checked (default: "1")
$unchecked_value - Value when unchecked (default: "0")
$disabled - Disable checkbox (grayed out, still returns value)
Examples:
<Checkbox_Input $label="Subscribe to newsletter" />
<Checkbox_Input $label="I agree to terms"
$checked_value="yes"
$unchecked_value="no" />
Wysiwyg_Input
Rich text editor using Quill.
Attributes:
$placeholder - Placeholder text
$disabled - Disable editor (not yet implemented)
$seeder - Seeder function name for test data
Example:
<Wysiwyg_Input $placeholder="Enter description..." />
SEEDING TEST DATA
The seed system generates realistic test data for forms during development.
Enabled only when window.rsxapp.debug is true.
Seed Button:
Rsx_Form automatically displays a "Fill Test Data" button in debug mode.
Clicking this button calls seed() on all widgets.
Widget Seeding:
Widgets implement seed() to generate appropriate test data:
async seed() {
if (this.args.seeder) {
// TODO: Implement Rsx_Random_Values endpoint
let value = 'Test ' + (this.args.seeder || 'Value');
this.val(value);
}
}
Seeder Names:
Specify seeder via $seeder attribute:
<Text_Input $seeder="company_name" />
<Text_Input $seeder="email" />
<Text_Input $seeder="phone" />
Future Implementation:
Planned Rsx_Random_Values endpoint will provide:
- company_name() - Random company names
- email() - Random email addresses
- phone() - Random phone numbers
- first_name() - Random first names
- last_name() - Random last names
- address() - Random street addresses
- city() - Random city names
DISABLED STATE
Disabled widgets display as read-only but still participate in form
value collection.
Behavior:
- Widget displays grayed-out appearance
- User cannot interact with widget
- val() getter still returns current value
- Value included in form submission via Ajax
- HTML disabled attribute prevents browser form submission
Example - Disable Individual Fields:
<Form_Field $name="email" $label="Email (Cannot Edit)">
<Text_Input $type="email" $disabled=true />
</Form_Field>
Example - Conditional Disable:
<Form_Field $name="status" $label="Status">
<Select_Input $options="{{ json_encode($statuses) }}"
$disabled="{{ !$can_edit_status }}" />
</Form_Field>
Use Cases:
- Display data that cannot be edited
- Show calculated or system-managed values
- Enforce permissions (some users see but cannot edit)
- Multi-step forms (disable completed steps)
FORM SUBMISSION
Form submission uses Ajax to send data to controller methods.
JavaScript Submission:
$('#save-btn').on('click', function() {
const $form = $('.Rsx_Form').first();
const form_component = $form.component();
form_component.submit();
});
What Happens:
1. Form calls vals() to collect all widget values
2. Sends values to this.args.action via Ajax.call()
3. Handles response:
- Success: Redirect if response.redirect provided
- Validation errors: Display errors on fields
- General errors: Log to console
Controller Method:
#[Ajax_Endpoint]
public static function save(Request $request, array $params = []) {
// Validation
$validated = $request->validate([
'email' => 'required|email',
'name' => 'required|string|max:255',
]);
// Save data
$user = User::create($validated);
// Return response
return [
'success' => true,
'redirect' => Rsx::Route('Users_Controller', 'view', $user->id),
];
}
Validation Errors:
When validation fails, return errors in format:
return [
'success' => false,
'errors' => [
'email' => 'The email field is required.',
'name' => 'The name field is required.',
],
];
Form automatically displays errors below each field.
MULTI-COLUMN LAYOUTS
Use Bootstrap grid for multi-column field layouts:
<div class="row">
<div class="col-md-6">
<Form_Field $name="first_name" $label="First Name">
<Text_Input />
</Form_Field>
</div>
<div class="col-md-6">
<Form_Field $name="last_name" $label="Last Name">
<Text_Input />
</Form_Field>
</div>
</div>
<div class="row">
<div class="col-md-4">
<Form_Field $name="city" $label="City">
<Text_Input />
</Form_Field>
</div>
<div class="col-md-2">
<Form_Field $name="state" $label="State">
<Text_Input $maxlength=2 />
</Form_Field>
</div>
<div class="col-md-6">
<Form_Field $name="zip" $label="ZIP Code">
<Text_Input />
</Form_Field>
</div>
</div>
CREATING CUSTOM WIDGETS
Create custom widgets by implementing the widget interface.
Example - Rating Widget:
File: rating_input.jqhtml
<Define:Rating_Input class="Widget">
<div class="rating">
<% for (let i = 1; i <= 5; i++) { %>
<i $id="star_<%= i %>"
class="bi bi-star<%= this.data.value >= i ? '-fill' : '' %>"
data-rating="<%= i %>"></i>
<% } %>
</div>
</Define:Rating_Input>
File: rating_input.js
class Rating_Input extends Form_Input_Abstract {
on_create() {
this.data.value = 0;
}
on_ready() {
const that = this;
this.$.find('[data-rating]').on('click', function() {
that.val($(this).data('rating'));
});
}
val(value) {
if (arguments.length === 0) {
return this.data.value;
} else {
this.data.value = value || 0;
// Update star display
this.$.find('[data-rating]').each(function() {
const rating = $(this).data('rating');
$(this).toggleClass('bi-star-fill', rating <= value);
$(this).toggleClass('bi-star', rating > value);
});
}
}
async seed() {
this.val(Math.floor(Math.random() * 5) + 1);
}
}
Usage:
<Form_Field $name="satisfaction" $label="Rate Your Experience">
<Rating_Input />
</Form_Field>
EXAMPLES
Complete Form Example:
@php
$form_data = isset($client) ? [
'name' => $client->name,
'email' => $client->email,
'industry' => $client->industry,
'active' => $client->active,
] : [];
$industries = ['Technology', 'Finance', 'Healthcare'];
@endphp
<Rsx_Form $data="{{ json_encode($form_data) }}"
$action="{{ Rsx::Route('Clients_Controller', 'save') }}">
@if (isset($client))
<input type="hidden" name="id" value="{{ $client->id }}">
@endif
<Form_Field $name="name" $label="Company Name" $required=true>
<Text_Input $seeder="company_name" />
</Form_Field>
<div class="row">
<div class="col-md-6">
<Form_Field $name="email" $label="Email">
<Text_Input $type="email" $seeder="email" />
</Form_Field>
</div>
<div class="col-md-6">
<Form_Field $name="phone" $label="Phone">
<Text_Input $type="tel" $seeder="phone" />
</Form_Field>
</div>
</div>
<Form_Field $name="industry" $label="Industry">
<Select_Input $options="{{ json_encode($industries) }}"
$placeholder="Select Industry..." />
</Form_Field>
<Form_Field $name="notes" $label="Notes">
<Text_Input $type="textarea" $rows=5 />
</Form_Field>
<Form_Field $name="active" $label="&nbsp;">
<Checkbox_Input $label="Active Client" />
</Form_Field>
<button type="button" class="btn btn-primary" id="save-btn">
Save Client
</button>
</Rsx_Form>
<script>
$('#save-btn').on('click', function() {
$('.Rsx_Form').first().component().submit();
});
</script>
SEE ALSO
jqhtml(3), ajax(3), validation(3)
RSX Framework October 2025 FORMS_AND_WIDGETS(3)

View File

@@ -92,7 +92,7 @@ TEMPLATE SYNTAX
Template expressions:
<%= expression %> - Escaped HTML output (safe, default)
<%== expression %> - Unescaped raw output (pre-sanitized content only)
<%!= expression %> - Unescaped raw output (pre-sanitized content only)
<% statement; %> - JavaScript statements (loops, conditionals)
Attributes:
@@ -103,6 +103,18 @@ TEMPLATE SYNTAX
data-attr="value" - HTML data attributes
class="my-class" - CSS classes (merged with component name)
Conditional Attributes (v2.2.162+):
Place if statements directly in attribute context to conditionally apply
attributes based on component arguments:
<input type="text"
<% if (this.args.required) { %>required="required"<% } %>
<% if (this.args.min !== undefined) { %>min="<%= this.args.min %>"<% } %> />
Works with static values, interpolated expressions, and multiple conditionals
per element. Compiles to Object.assign() with ternary operators at build time.
No nested conditionals or else clauses supported - use separate if statements.
DEFINE TAG CONFIGURATION
The <Define> tag supports three types of attributes:
@@ -350,32 +362,35 @@ CONTROL FLOW AND LOOPS
COMPONENT LIFECYCLE
Five-stage deterministic lifecycle:
render → on_render → on_create → on_load → on_ready
on_create → render → on_render → on_load → on_ready
1. render (automatic, top-down)
1. on_create() (synchronous, runs BEFORE first render)
- Setup default state BEFORE template executes
- Initialize this.data properties so template can reference them
- Must be synchronous (no async/await)
- Perfect for abstract component base classes
- Example: this.data.rows = this.data.rows || [];
2. render (automatic, top-down)
- Template executes, DOM created
- First render: this.data = {} (empty object)
- First render: can safely reference properties set in on_create()
- Parent completes before children
- Not overridable
2. on_render() (top-down)
3. on_render() (top-down)
- Fires immediately after render, BEFORE children ready
- Hide uninitialized elements
- Set initial visual state
- Prevents flash of uninitialized content
- Parent completes before children
3. on_create() (bottom-up)
- Quick synchronous setup
- Set instance properties
- Children complete before parent
4. on_load() (bottom-up, siblings in parallel)
4. on_load() (bottom-up, siblings in parallel, CAN be async)
- Load async data
- ONLY modify this.data - NEVER DOM
- NO child component access
- Siblings at same depth execute in parallel
- Children complete before parent
- If this.data changes, triggers automatic re-render
5. on_ready() (bottom-up)
- All children guaranteed ready
@@ -385,22 +400,67 @@ COMPONENT LIFECYCLE
- Children complete before parent
Depth-Ordered Execution:
- First: on_create runs before anything else (setup state)
- Top-down: render, on_render (parent before children)
- Bottom-up: on_create, on_load, on_ready (children before parent)
- Bottom-up: on_load, on_ready (children before parent)
- Parallel: Siblings at same depth during on_load()
Critical rules:
- Use on_create() to initialize default state before template runs
- Never modify DOM in on_load() - only update this.data
- on_load() runs in parallel for siblings (DOM unpredictable)
- Data changes during load trigger single re-render
- Data changes during load trigger automatic re-render
- on_create(), on_render(), on_destroy() must be synchronous
- on_load() and on_ready() can be async
ON_CREATE() USE CASES
The on_create() method runs BEFORE the first render, making it perfect
for initializing default state that templates will reference:
Example: Preventing "not iterable" errors
class DataGrid_Abstract extends Jqhtml_Component {
on_create() {
// Initialize defaults BEFORE template renders
this.data.rows = [];
this.data.loading = true;
this.data.is_empty = false;
}
async on_ready() {
// Later: load actual data
await this.load_page(1);
}
}
Template can now safely iterate:
<% for(let row of this.data.rows) { %>
<%= content('row', row); %>
<% } %>
Without on_create(), template would fail with "this.data.rows is not
iterable" because this.data starts as {} before on_load() runs.
Abstract Component Pattern:
Use on_create() in abstract base classes to ensure child templates
have required properties initialized:
class Form_Abstract extends Jqhtml_Component {
on_create() {
// Set defaults that all forms need
this.data.fields = this.data.fields || [];
this.data.errors = this.data.errors || {};
this.data.submitting = false;
}
}
DOUBLE-RENDER PATTERN
Components may render TWICE if on_load() modifies this.data:
1. First render: this.data = {} (empty)
2. on_load() populates this.data
3. Automatic re-render with populated data
4. on_ready() fires after second render (only once)
1. on_create() sets defaults: this.data.rows = []
2. First render: template uses empty rows array
3. on_load() populates this.data.rows with actual data
4. Automatic re-render with populated data
5. on_ready() fires after second render (only once)
Use for loading states:
<Define:Product_List>

View File

@@ -139,6 +139,76 @@ JQUERY HELPERS
$(this).attr('target', '_blank');
}
Component-Aware DOM Traversal
.shallowFind(selector)
Finds child elements matching the selector that don't have another
element of the same class as a parent between them and the component.
Useful for finding direct widget children in nested component trees
without accidentally selecting widgets from nested child components.
Example:
Component_A
└── div
└── Widget (found)
└── span
└── Widget (not found - has Widget parent)
$('.Component_A').shallowFind('.Widget')
// Returns only the first Widget
Use case - Finding form widgets without selecting nested widgets:
this.$.shallowFind('.Form_Field').each(function() {
// Only processes fields directly in this form,
// not fields in nested sub-forms
});
.closest_sibling(selector)
Searches for elements within progressively higher ancestor containers.
Similar to .closest() but searches within ancestors instead of
matching the ancestors themselves. Stops searching at <body> tag.
Useful for component-to-component communication when components need
to find related sibling or cousin components without knowing the
exact DOM structure.
Algorithm:
1. Get current element's parent
2. Search within parent using parent.find(selector)
3. If found, return results
4. If not found, move to parent's parent and repeat
5. Stop when reaching <body> (searches body but not beyond)
6. Return empty jQuery object if nothing found
Example DOM structure:
<body>
<div class="form-section">
<div class="row">
<div class="Country_Select_Input"></div>
</div>
<div class="another-row">
<div class="State_Select_Input"></div>
</div>
</div>
</body>
$('.Country_Select_Input').closest_sibling('.State_Select_Input')
// Finds State_Select_Input by searching up to form-section
Use case - Country selector updating state selector:
on_ready() {
if (this.tom_select) {
this.tom_select.on('change', () => {
const state_input = this.$el.closest_sibling('.State_Select_Input');
if (state_input.exists()) {
const widget = state_input.component();
widget.set_country_code(this.val());
}
});
}
}
Form Validation
.checkValidity()

724
app/RSpade/man/modals.txt Executable file
View File

@@ -0,0 +1,724 @@
================================================================================
MODAL SYSTEM
================================================================================
The Modal system provides a consistent, queue-managed interface for displaying
dialogs throughout the application. All modals are managed by the static Modal
class, which handles queuing, backdrop management, and user interactions.
================================================================================
BASIC DIALOGS
================================================================================
ALERT
-----
Show a simple notification message with an OK button.
await Modal.alert(message)
await Modal.alert(title, message)
await Modal.alert(title, message, button_label)
Examples:
await Modal.alert("File saved successfully");
await Modal.alert("Success", "Your changes have been saved");
await Modal.alert("Notice", "Operation complete", "Got it");
Parameters:
message - Message text (if only 1 arg) or jQuery element
title - Optional title (default: "Notice")
button_label - Optional button text (default: "OK")
Returns: Promise<void>
CONFIRM
-------
Show a confirmation dialog with Cancel and Confirm buttons.
let result = await Modal.confirm(message)
let result = await Modal.confirm(title, message)
let result = await Modal.confirm(title, message, confirm_label, cancel_label)
Examples:
if (await Modal.confirm("Delete this item?")) {
// User confirmed
}
if (await Modal.confirm("Delete Item", "This cannot be undone")) {
// User confirmed
}
Parameters:
message - Message text (if 1-2 args) or jQuery element
title - Optional title (default: "Confirm")
confirm_label - Confirm button text (default: "Confirm")
cancel_label - Cancel button text (default: "Cancel")
Returns: Promise<boolean> - true if confirmed, false if cancelled
PROMPT
------
Show an input dialog for text entry.
let value = await Modal.prompt(message)
let value = await Modal.prompt(title, message)
let value = await Modal.prompt(title, message, default_value)
let value = await Modal.prompt(title, message, default_value, multiline)
let value = await Modal.prompt(title, message, default_value, multiline, error)
Examples:
let name = await Modal.prompt("What is your name?");
if (name) {
console.log("Hello, " + name);
}
let email = await Modal.prompt("Email", "Enter your email:", "user@example.com");
let feedback = await Modal.prompt("Feedback", "Enter your feedback:", "", true);
Rich Content Example:
const $rich = $('<div>')
.append($('<h5 style="color: #2c3e50;">').text('Registration'))
.append($('<p>').html('Enter your <strong>full name</strong>'));
let name = await Modal.prompt($rich);
Validation Pattern (Lazy Re-prompting):
let email = '';
let error = null;
let valid = false;
while (!valid) {
email = await Modal.prompt('Email', 'Enter email:', email, false, error);
if (email === false) return; // Cancelled
// Validate
if (!email.includes('@')) {
error = 'Please enter a valid email address';
} else {
valid = true;
}
}
// email is now valid
Parameters:
message - Prompt message text or jQuery element
title - Optional title (default: "Input")
default_value - Default input value (default: "")
multiline - Show textarea instead of input (default: false)
error - Optional error message to display as validation feedback
Returns: Promise<string|false> - Input value or false if cancelled
Input Constraints:
Standard input: 245px minimum width
Textarea input: 315px minimum width
Spacing: 36px between message and input field
Error Display:
When error parameter is provided:
- Input field marked with .is-invalid class (red border)
- Error message displayed below input as .invalid-feedback
- Input retains previously entered value
- User can correct and resubmit
ERROR
-----
Show an error message dialog.
await Modal.error(error)
await Modal.error(error, title)
Examples:
await Modal.error("File not found");
await Modal.error(exception, "Upload Failed");
await Modal.error({message: "Invalid format"}, "Error");
Parameters:
error - String, error object, or {message: string}
title - Optional title (default: "Error")
Handles various error formats:
- String: "Error message"
- Object: {message: "Error"}
- Laravel response: {responseJSON: {message: "Error"}}
- Field errors: {field: "Error", field2: "Error2"}
Returns: Promise<void>
================================================================================
CUSTOM MODALS
================================================================================
SHOW
----
Display a custom modal with specified content and buttons.
let result = await Modal.show(options)
Options:
title - Modal title (default: "Modal")
body - String, HTML, or jQuery element
buttons - Array of button definitions (see below)
max_width - Maximum width in pixels (default: 800)
closable - Allow ESC/backdrop/X to close (default: true)
Button Definition:
{
label: "Button Text",
value: "return_value",
class: "btn-primary", // Bootstrap button class
default: true, // Make this the default button
callback: async function() {
// Optional: perform action and return result
return custom_value;
}
}
Examples:
// Two button modal
const result = await Modal.show({
title: "Choose Action",
body: "What would you like to do?",
buttons: [
{label: "Cancel", value: false, class: "btn-secondary"},
{label: "Continue", value: true, class: "btn-primary", default: true}
]
});
// Three button modal
const result = await Modal.show({
title: "Save Changes",
body: "How would you like to save?",
buttons: [
{label: "Cancel", value: false, class: "btn-secondary"},
{label: "Save Draft", value: "draft", class: "btn-info"},
{label: "Publish", value: "publish", class: "btn-success", default: true}
]
});
// jQuery content
const $content = $('<div>')
.append($('<p>').text('Custom content'))
.append($('<ul>').append($('<li>').text('Item 1')));
await Modal.show({
title: "Custom Content",
body: $content,
buttons: [{label: "Close", value: true, class: "btn-primary"}]
});
Returns: Promise<any> - Value from clicked button (false if cancelled)
================================================================================
FORM MODALS
================================================================================
FORM
----
Display a form component in a modal with validation support.
let result = await Modal.form(options)
Options:
component - Component class name (string)
component_args - Arguments to pass to component
title - Modal title (default: "Form")
max_width - Maximum width in pixels (default: 800)
closable - Allow ESC/backdrop to close (default: true)
submit_label - Submit button text (default: "Submit")
cancel_label - Cancel button text (default: "Cancel")
on_submit - Callback function (receives form component)
The on_submit callback pattern:
- Receives the form component instance
- Call form.vals() to get current values
- Perform validation/submission
- Return false to keep modal open (for errors)
- Return data to close modal and resolve promise
Simple Example:
const result = await Modal.form({
title: "New User",
component: "User_Form",
on_submit: async (form) => {
const values = form.vals();
if (!values.name) {
await Modal.alert("Name is required");
return false; // Keep modal open
}
await sleep(500); // Simulate save
return values; // Close modal with data
}
});
if (result) {
console.log("Saved:", result);
}
Validation Example:
const result = await Modal.form({
title: "Edit Profile",
component: "Profile_Form",
component_args: {data: user_data},
submit_label: "Update",
on_submit: async (form) => {
const values = form.vals();
// Server-side validation
const response = await User_Controller.update_profile(values);
if (response.errors) {
// Show errors and keep modal open
Form_Utils.apply_form_errors(form.$, response.errors);
return false;
}
// Success - close and return
return response.data;
}
});
Creating Form Components:
Your form component must:
- Extend Jqhtml_Component
- Implement vals() method for getting/setting values
- Use standard form HTML with name attributes
- Include error container: <div $id="error_container"></div>
Example form component (my_form.jqhtml):
<Define:My_Form tag="div">
<div $id="error_container"></div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" $id="name_input" name="name">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" $id="email_input" name="email">
</div>
</Define:My_Form>
Example form component class (my_form.js):
class My_Form extends Jqhtml_Component {
on_create() {
this.data.values = this.args.data || {};
}
on_ready() {
if (this.data.values) {
this.vals(this.data.values);
}
}
vals(values) {
if (values) {
// Setter
this.$id('name_input').val(values.name || '');
this.$id('email_input').val(values.email || '');
return null;
} else {
// Getter
return {
name: this.$id('name_input').val(),
email: this.$id('email_input').val()
};
}
}
}
Validation Error Handling:
Form_Utils.apply_form_errors() automatically handles:
- Field-specific errors (matched by name attribute)
- General error messages
- Multiple error formats (string, array, object)
- Animated error display
- Bootstrap 5 validation classes
Error format examples:
// Field errors
{
name: "Name is required",
email: "Invalid email format"
}
// General errors
"An error occurred"
// Array of errors
["Error 1", "Error 2"]
// Laravel format
{
name: ["Name is required", "Name too short"],
email: ["Invalid format"]
}
Returns: Promise<Object|false> - Form data or false if cancelled
================================================================================
SPECIAL MODALS
================================================================================
UNCLOSABLE
----------
Display a modal that cannot be closed by user (no ESC, backdrop, or X button).
Must be closed programmatically.
Modal.unclosable(message)
Modal.unclosable(title, message)
Examples:
Modal.unclosable("Processing", "Please wait...");
setTimeout(() => {
Modal.close();
}, 3000);
Parameters:
message - Message text
title - Optional title (default: "Please Wait")
Returns: void (does not wait for close)
Note: Call Modal.close() to dismiss the modal programmatically.
================================================================================
MODAL STATE MANAGEMENT
================================================================================
IS_OPEN
-------
Check if a modal is currently displayed.
if (Modal.is_open()) {
console.log("Modal is open");
}
Returns: boolean
GET_CURRENT
-----------
Get the currently displayed modal instance.
const modal = Modal.get_current();
if (modal) {
console.log("Modal instance exists");
}
Returns: Rsx_Modal instance or null
CLOSE
-----
Programmatically close the current modal.
await Modal.close();
Typically used with unclosable modals or to force-close from external code.
Returns: Promise<void>
APPLY_ERRORS
------------
Apply validation errors to the current modal (if it contains a form).
Modal.apply_errors({
field1: "Error message",
field2: "Another error"
});
This is a convenience method that calls Form_Utils.apply_form_errors() on
the current modal's body element.
Parameters:
errors - Error object (field: message pairs)
Returns: void
================================================================================
MODAL QUEUING
================================================================================
The Modal system automatically queues multiple simultaneous modal requests
and displays them sequentially:
// All three modals are queued and shown one after another
const p1 = Modal.alert("First");
const p2 = Modal.alert("Second");
const p3 = Modal.alert("Third");
await Promise.all([p1, p2, p3]);
Queuing Behavior:
- Single shared backdrop persists across queued modals
- 500ms delay between modals (backdrop stays visible)
- Backdrop fades in at start of queue
- Backdrop fades out when queue is empty
- Each modal appears instantly (no fade animation)
Current Limitations:
- All modals treated equally (no priority levels)
- No concept of "modal sessions" or grouped interactions
- FIFO queue order (first requested, first shown)
Future Considerations:
When implementing real-time notifications or background events, you may
need to distinguish between:
- User-initiated modal sequences (conversational flow)
- Background notifications (should wait for user flow to complete)
Planned features:
- Priority levels for different modal types
- Modal sessions to group related interactions
- External event blocking during active user sessions
================================================================================
MODAL SIZING
================================================================================
Responsive Sizing:
- Desktop: 60% viewport width preferred, max 80%
- Mobile: 90% viewport width
- Minimum width: 400px desktop, 280px mobile
- Minimum height: 260px
Maximum Width:
- Default: 800px
- Configurable via max_width option
- Examples: 500px (forms), 1200px (data tables)
Scrolling:
- Triggers when content exceeds 80% viewport height
- Modal body becomes scrollable
- Header and footer remain fixed
Manual Control:
Modal.show({
max_width: 1200, // Wide modal for tables
body: content
});
================================================================================
STYLING AND UX
================================================================================
Modal Appearance:
- Centered vertically and horizontally
- Gray header background (#f8f9fa)
- Smaller title font (1rem)
- Shorter header padding (0.75rem)
- Subtle drop shadow (0 4px 12px rgba(0,0,0,0.15))
- Buttons centered horizontally as a group
- Modal body text centered (for simple dialogs)
Animations:
- Modal appears instantly (no fade)
- Backdrop fades in/out over 250ms
- Validation errors fade in over 300ms
Body Scroll Lock:
- Page scrolling disabled when modal open
- Scrollbar width calculated and compensated via padding
- Prevents layout shift when scrollbar disappears
- Original body state restored when modal closes
- Managed at backdrop level (first modal locks, last unlocks)
Accessibility:
- ESC key closes modal (if closable)
- Backdrop click closes modal (if closable)
- Focus management (input fields auto-focus)
- Keyboard navigation support
================================================================================
BEST PRACTICES
================================================================================
1. Use Appropriate Dialog Type
- alert() - Notifications, information
- confirm() - Yes/no decisions
- prompt() - Simple text input
- form() - Complex forms with validation
- show() - Custom requirements
2. Handle Cancellations
Always check for false (cancelled) return values:
const result = await Modal.confirm("Delete?");
if (result === false) {
return; // User cancelled
}
3. Validation Feedback
Keep modal open for validation errors:
if (errors) {
Form_Utils.apply_form_errors(form.$, errors);
return false; // Keep open
}
4. Avoid Nested Modals
While technically possible, nested modals create poor UX.
Close the first modal before showing a second:
await Modal.alert("Step 1");
await Modal.alert("Step 2"); // Shows after first closes
5. Loading States
For long operations, use unclosable modals:
Modal.unclosable("Saving", "Please wait...");
await save_operation();
await Modal.close();
6. Rich Content
Use jQuery elements for formatted content:
const $content = $('<div>')
.append($('<h5>').text('Title'))
.append($('<p>').html('<strong>Bold</strong> text'));
await Modal.alert($content);
7. Form Component Design
- Keep vals() method simple and synchronous
- Put async logic in on_submit callback
- Use standard HTML form structure
- Include error_container div for validation
- Match field name attributes to error keys
================================================================================
COMMON PATTERNS
================================================================================
Delete Confirmation:
const confirmed = await Modal.confirm(
"Delete Item",
"This action cannot be undone. Are you sure?",
"Delete Forever",
"Cancel"
);
if (confirmed) {
await Item_Controller.delete(item_id);
await Modal.alert("Item deleted successfully");
}
Save with Validation:
const result = await Modal.form({
title: "Edit Profile",
component: "Profile_Form",
component_args: {data: user},
on_submit: async (form) => {
const values = form.vals();
const response = await User_Controller.save(values);
if (response.errors) {
Form_Utils.apply_form_errors(form.$, response.errors);
return false;
}
return response.data;
}
});
if (result) {
await Modal.alert("Profile updated successfully");
}
Multi-Step Process:
const name = await Modal.prompt("What is your name?");
if (!name) return;
const email = await Modal.prompt("Enter your email:");
if (!email) return;
const confirmed = await Modal.confirm(
"Confirm Registration",
`Register ${name} with ${email}?`
);
if (confirmed) {
await register({name, email});
}
Progressive Disclosure:
const result = await Modal.show({
title: "Choose Action",
body: "What would you like to do?",
buttons: [
{label: "View Details", value: "view"},
{label: "Edit", value: "edit"},
{label: "Delete", value: "delete", class: "btn-danger"}
]
});
if (result === "view") {
// Show details modal
} else if (result === "edit") {
// Show edit form
} else if (result === "delete") {
// Confirm and delete
}
================================================================================
TROUBLESHOOTING
================================================================================
Modal Won't Close
- Check if callback returns false (intentionally keeping open)
- Verify closable: true option is set
- Check for JavaScript errors in callback
- Use Modal.close() to force close
Validation Errors Not Showing
- Ensure form has <div $id="error_container"></div>
- Verify field name attributes match error keys
- Check that fields are wrapped in .form-group containers
- Use Form_Utils.apply_form_errors(form.$, errors)
Form Values Not Saving
- Verify vals() method returns correct object
- Check that on_submit returns data (not false)
- Ensure callback doesn't throw unhandled errors
- Test vals() method independently
Queue Not Working
- All modals automatically queue
- If backdrop flickers, check for multiple backdrop creation
- Verify using Modal.* static methods (not creating instances)
Component Not Found
- Ensure component class name is correct (case-sensitive)
- Check that component files are in manifest
- Verify component extends Jqhtml_Component
- Component must be in /rsx/ directory tree
================================================================================

View File

@@ -12,7 +12,7 @@ DESCRIPTION
Key differences from Laravel:
- Laravel: route('user.profile', $user) using named routes
- RSX: Rsx::Route('User_Controller', 'profile')->url(['id' => $user->id])
- RSX: Rsx::Route('User_Controller', 'profile', ['id' => $user->id])
Benefits:
- No route name management required
@@ -25,30 +25,24 @@ BASIC USAGE
PHP Syntax:
use App\RSpade\Core\Rsx;
// Create route proxy (action defaults to 'index')
$route = Rsx::Route('Demo_Index_Controller');
$route = Rsx::Route('Demo_Index_Controller', 'show');
// Generate URLs (returns string directly)
$url = Rsx::Route('Demo_Index_Controller'); // /demo
$url = Rsx::Route('Demo_Index_Controller', 'show'); // /demo/show
$url = Rsx::Route('Demo_Index_Controller', 'show', ['id' => 123]); // /demo/123
$url = Rsx::Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
// Generate URLs
$url = $route->url(); // /demo
$url = $route->url(['id' => 123]); // /demo/123 or /demo?id=123
$absolute = $route->absolute_url(); // https://site.com/demo
// Navigate (redirect)
$route->navigate(); // Sends Location header and exits
// Use in redirects
return redirect(Rsx::Route('Demo_Index_Controller'));
JavaScript Syntax:
// Create route proxy (action defaults to 'index')
const route = Rsx.Route('Demo_Index_Controller');
const route = Rsx.Route('Demo_Index_Controller', 'show');
// Generate URLs (returns string directly)
const url = Rsx.Route('Demo_Index_Controller'); // /demo
const url = Rsx.Route('Demo_Index_Controller', 'show'); // /demo/show
const url = Rsx.Route('Demo_Index_Controller', 'show', {id: 123}); // /demo/123
const url = Rsx.Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
// Generate URLs
const url = route.url(); // /demo
const url = route.url({id: 123}); // /demo/123 or /demo?id=123
const absolute = route.absolute_url(); // https://site.com/demo
// Navigate
route.navigate(); // Sets window.location.href
// Use in navigation
window.location.href = Rsx.Route('Demo_Index_Controller');
ROUTE PATTERNS
Route Definition:
@@ -149,15 +143,18 @@ ADVANCED PATTERNS
Complex Parameter Examples:
// Multiple parameters
#[Route('/api/v1/users/:company/:division/:id')]
$route->url(['company' => 'acme', 'division' => 'sales', 'id' => 123]);
$url = Rsx::Route('Api_V1_Users_Controller', 'show',
['company' => 'acme', 'division' => 'sales', 'id' => 123]);
// Result: /api/v1/users/acme/sales/123
// Query parameters for extra values
$route->url(['id' => 123, 'format' => 'json', 'include' => 'profile']);
$url = Rsx::Route('Demo_Controller', 'show',
['id' => 123, 'format' => 'json', 'include' => 'profile']);
// Result: /demo/123?format=json&include=profile
// Complex objects as parameters
$route->url(['filter' => ['status' => 'active', 'type' => 'user']]);
$url = Rsx::Route('Demo_Controller', 'index',
['filter' => ['status' => 'active', 'type' => 'user']]);
// Result: /demo?filter[status]=active&filter[type]=user
Route Groups and Prefixes:
@@ -194,8 +191,7 @@ JAVASCRIPT BUNDLE ROUTES
Runtime Route Access:
// Routes available after bundle loads
if (typeof Rsx !== 'undefined') {
const route = Rsx.Route('Demo_Index_Controller');
const url = route.url();
const url = Rsx.Route('Demo_Index_Controller');
}
ERROR HANDLING
@@ -218,7 +214,7 @@ DEBUGGING ROUTES
php artisan rsx:routes # List all discovered routes
Test Route Generation:
php artisan rsx:debug /demo --eval="Rsx.Route('Demo_Index_Controller').url()"
php artisan rsx:debug /demo --eval="Rsx.Route('Demo_Index_Controller')"
Route Information:
php artisan rsx:manifest:show # View route cache in manifest
@@ -229,21 +225,21 @@ COMMON PATTERNS
public static function handle_form(Request $request, array $params = [])
{
// Process form...
Rsx::Route('Dashboard_Index_Controller')->navigate();
return redirect(Rsx::Route('Dashboard_Index_Controller'));
}
AJAX URL Generation:
// Generate URLs for AJAX calls
const apiUrl = Rsx.Route('Api_User_Controller', 'update').url({id: userId});
const apiUrl = Rsx.Route('Api_User_Controller', 'update', {id: userId});
fetch(apiUrl, {method: 'POST', body: formData});
Form Action URLs:
// Generate form action URLs
<form action="<?= Rsx::Route('User_Profile_Controller', 'update')->url() ?>" method="POST">
<form action="<?= Rsx::Route('User_Profile_Controller', 'update') ?>" method="POST">
Link Generation:
// Generate navigation links
<a href="<?= Rsx::Route('Dashboard_Index_Controller')->url() ?>">Dashboard</a>
<a href="<?= Rsx::Route('Dashboard_Index_Controller') ?>">Dashboard</a>
TROUBLESHOOTING
Route Not Found:

212
app/RSpade/man/tasks.txt Executable file
View File

@@ -0,0 +1,212 @@
RSX TASK SYSTEM
================
The RSX Task system provides a structured way to define and execute background
tasks, similar to how Controllers handle HTTP requests but for CLI/background
execution.
## OVERVIEW
Tasks are static methods in Service classes that can be executed from:
- Command line (via `rsx:task:run`)
- Internal PHP code (via `Task::internal()`)
- Future: Queue systems, cron scheduling, progress tracking
## CREATING SERVICES
Services are classes that extend `Rsx_Service_Abstract` and live in:
/rsx/services/
Example service structure:
<?php
use App\RSpade\Core\Service\Rsx_Service_Abstract;
class My_Service extends Rsx_Service_Abstract
{
#[Task('Description of what this task does')]
public static function my_task(array $params = [])
{
// Task implementation
return [
'message' => 'Task completed',
'data' => 'result data'
];
}
}
## TASK METHODS
Tasks must:
- Be public static methods
- Have the #[Task('description')] attribute
- Accept array $params = [] parameter
- Return data (will be JSON-encoded for CLI output)
Task signature:
public static function task_name(array $params = [])
## PARAMETER HANDLING
Services inherit parameter helpers from Rsx_Service_Abstract:
protected static function __param($params, $key, $default = null)
protected static function __has_param($params, $key)
Example usage:
$name = static::__param($params, 'name', 'Guest');
if (static::__has_param($params, 'force')) {
// ...
}
## PRE-TASK HOOK
Override pre_task() to run validation/auth before any task executes:
public static function pre_task(array $params = [])
{
if (!RsxAuth::check()) {
throw new \Exception("Authentication required");
}
return null; // Continue to task
}
If pre_task() returns non-null, task execution halts and returns that value.
## LISTING TASKS
View all available tasks:
php artisan rsx:task:list
Output shows services and their tasks with descriptions:
Service_Test
hello_world - Test task with no arguments
greet - Test task with optional name parameter
calculate - Test task with multiple parameters
## RUNNING TASKS
Execute a task from command line:
php artisan rsx:task:run Service task_name
With parameters:
php artisan rsx:task:run Service task_name --param=value
php artisan rsx:task:run Service greet --name=John
Boolean flags:
php artisan rsx:task:run Service task_name --force
JSON values (auto-parsed):
php artisan rsx:task:run Service task_name --data='{"key":"value"}'
Debug mode (wrapped response):
php artisan rsx:task:run Service task_name --debug
## OUTPUT MODES
Default mode - Raw JSON response (just the return value):
{
"message": "Task completed",
"count": 42
}
Debug mode - Wrapped response with success indicator:
{
"success": true,
"result": {
"message": "Task completed",
"count": 42
}
}
## INTERNAL TASK CALLS
Call tasks from PHP code using Task::internal():
use App\RSpade\Core\Task\Task;
$result = Task::internal('Service_Name', 'task_name', [
'param1' => 'value1',
'param2' => 'value2'
]);
This is useful for:
- Composing complex tasks from simpler ones
- Calling tasks from controllers
- Background job processing
Example composition:
#[Task('Run all seeders')]
public static function seed_all(array $params = [])
{
$clients = Task::internal('Seeder_Service', 'seed_clients', $params);
$contacts = Task::internal('Seeder_Service', 'seed_contacts', $params);
return [
'clients' => $clients,
'contacts' => $contacts
];
}
## ERROR HANDLING
All errors return JSON (never throws to stderr):
{
"success": false,
"error": "Error message",
"error_type": "Exception",
"trace": "..."
}
Exit codes:
- 0: Success
- 1: Error
## ATTRIBUTE CONFLICTS
A method cannot have multiple execution type attributes. These conflict:
- #[Route] (HTTP routes)
- #[Ajax_Endpoint] (Ajax endpoints)
- #[Task] (CLI tasks)
The manifest build will fail if these are mixed on the same method.
## USE CASES
Tasks are ideal for:
- Database seeders
- Data migrations
- Report generation
- Batch processing
- Maintenance operations
- Background jobs
- Scheduled operations
Example services:
Seeder_Service - Database seeding
Report_Service - Generate reports
Cleanup_Service - Maintenance tasks
Import_Service - Data imports
Export_Service - Data exports
## FUTURE FEATURES (NOT YET IMPLEMENTED)
The Task system is designed to support future enhancements:
- Queue integration (dispatch tasks to Redis/database queue)
- Cron scheduling (#[Schedule] attribute)
- Progress tracking (long-running tasks report progress)
- Task history (log of all task executions)
- Task dependencies (ensure X runs before Y)
- Parallel execution (run multiple tasks concurrently)
## EXAMPLES
See example services:
/rsx/services/service_test.php - Basic task examples
/rsx/services/seeder_service.php - Database seeding examples
Test tasks:
php artisan rsx:task:list
php artisan rsx:task:run Service_Test hello_world
php artisan rsx:task:run Service_Test greet --name=Brian
php artisan rsx:task:run Service_Test calculate --a=10 --b=5 --op=multiply

View File

@@ -88,7 +88,6 @@ class RspadeClassRefactorProvider {
});
if (!new_class_name) {
this.output_channel.appendLine('Refactor cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
// Confirm refactoring
@@ -96,7 +95,6 @@ class RspadeClassRefactorProvider {
'This will rename the class across all usages in all files.', { modal: true }, 'Rename', 'Cancel');
if (confirmation !== 'Rename') {
this.output_channel.appendLine('Global rename cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
// Save all dirty files first
@@ -132,25 +130,22 @@ class RspadeClassRefactorProvider {
this.output_channel.appendLine('\n=== Refactor Complete ===');
// Check if refactor was successful
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files and auto-rename
// Wait for filesystem changes to propagate then reload files and auto-rename
// Note: Panel is kept open so developer can see results and warnings
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
await this.reload_all_open_files();
// Wait another 500ms then check if current file needs renaming
setTimeout(async () => {
await this.reload_all_open_files();
// Wait another 500ms then check if current file needs renaming
setTimeout(async () => {
const editor = vscode.window.activeTextEditor;
if (editor) {
const file_path = editor.document.uri.fsPath;
// Only auto-rename if file is in ./rsx
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
await this.auto_rename_provider.check_and_rename(editor.document);
}
const editor = vscode.window.activeTextEditor;
if (editor) {
const file_path = editor.document.uri.fsPath;
// Only auto-rename if file is in ./rsx
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
await this.auto_rename_provider.check_and_rename(editor.document);
}
}, 500);
}
}, 500);
}, 3500);
}, 500);
vscode.window.showInformationMessage(`Successfully refactored ${class_info.class_name} to ${new_class_name}`);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,119 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CombinedSemanticTokensProvider = void 0;
const vscode = __importStar(require("vscode"));
const jqhtml_lifecycle_provider_1 = require("./jqhtml_lifecycle_provider");
const comment_file_reference_provider_1 = require("./comment_file_reference_provider");
const that_variable_provider_1 = require("./that_variable_provider");
/**
* Combined semantic tokens provider that merges tokens from multiple providers
*
* VS Code only allows one SemanticTokensLegend per language, so we need to
* combine all our providers into one to avoid conflicts.
*/
class CombinedSemanticTokensProvider {
constructor() {
this.jqhtml_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleSemanticTokensProvider();
this.file_ref_provider = new comment_file_reference_provider_1.CommentFileReferenceSemanticTokensProvider();
this.that_provider = new that_variable_provider_1.ThatVariableSemanticTokensProvider();
}
async provideDocumentSemanticTokens(document) {
// Get tokens from all providers
const jqhtml_tokens = await this.jqhtml_provider.provideDocumentSemanticTokens(document);
const file_ref_tokens = await this.file_ref_provider.provideDocumentSemanticTokens(document);
const that_tokens = await this.that_provider.provideDocumentSemanticTokens(document);
// Decode all tokens to absolute positions
const decoded_tokens = [];
// Decode JQHTML tokens (type 0 = conventionMethod, orange)
this.decode_tokens(jqhtml_tokens.data, 0, decoded_tokens);
// Decode file reference tokens (type 1 = class, teal)
this.decode_tokens(file_ref_tokens.data, 1, decoded_tokens);
// Decode 'that' tokens (type 2 = macro, dark blue #569CD6 like 'this')
this.decode_tokens(that_tokens.data, 2, decoded_tokens);
// Sort tokens by line, then by character
decoded_tokens.sort((a, b) => {
if (a.line !== b.line) {
return a.line - b.line;
}
return a.char - b.char;
});
// Re-encode tokens with delta encoding
const builder = new vscode.SemanticTokensBuilder();
for (const token of decoded_tokens) {
builder.push(token.line, token.char, token.length, token.type, token.modifiers);
}
return builder.build();
}
decode_tokens(data, new_type, output) {
let current_line = 0;
let current_char = 0;
for (let i = 0; i < data.length; i += 5) {
const delta_line = data[i];
const delta_char = data[i + 1];
const length = data[i + 2];
const modifiers = data[i + 4];
if (delta_line > 0) {
current_line += delta_line;
current_char = delta_char;
}
else {
current_char += delta_char;
}
output.push({
line: current_line,
char: current_char,
length: length,
type: new_type,
modifiers: modifiers
});
}
}
decode_tokens_with_modifier(data, new_type, new_modifier, output) {
let current_line = 0;
let current_char = 0;
for (let i = 0; i < data.length; i += 5) {
const delta_line = data[i];
const delta_char = data[i + 1];
const length = data[i + 2];
if (delta_line > 0) {
current_line += delta_line;
current_char = delta_char;
}
else {
current_char += delta_char;
}
output.push({
line: current_line,
char: current_char,
length: length,
type: new_type,
modifiers: new_modifier
});
}
}
}
exports.CombinedSemanticTokensProvider = CombinedSemanticTokensProvider;
//# sourceMappingURL=combined_semantic_provider.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"combined_semantic_provider.js","sourceRoot":"","sources":["../src/combined_semantic_provider.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,164 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CommentFileReferenceDefinitionProvider = exports.CommentFileReferenceSemanticTokensProvider = void 0;
const vscode = __importStar(require("vscode"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
/**
* Check if position is inside a comment
*/
function is_in_comment(document, position) {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Check for single-line comment
const single_comment_idx = line_text.indexOf('//');
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
return true;
}
// Check for block comment markers
const doc_comment_idx = line_text.indexOf('/*');
const doc_comment_end_idx = line_text.indexOf('*/');
const asterisk_comment = line_text.trim().startsWith('*');
if (asterisk_comment || doc_comment_idx !== -1 || doc_comment_end_idx !== -1) {
return true;
}
// Check for multi-line comment by looking at text before position
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
let in_block_comment = false;
let i = 0;
while (i < text_before.length) {
if (text_before.substring(i, i + 2) === '/*') {
in_block_comment = true;
i += 2;
}
else if (text_before.substring(i, i + 2) === '*/') {
in_block_comment = false;
i += 2;
}
else {
i++;
}
}
return in_block_comment;
}
/**
* Get the word at position, including periods
*/
function get_word_with_period(document, position) {
const line = document.lineAt(position.line);
const line_text = line.text;
const char = position.character;
// Find start of word (alphanumeric, underscore, period, hyphen)
let start = char;
while (start > 0 && /[a-zA-Z0-9_.-]/.test(line_text[start - 1])) {
start--;
}
// Find end of word
let end = char;
while (end < line_text.length && /[a-zA-Z0-9_.-]/.test(line_text[end])) {
end++;
}
const word = line_text.substring(start, end);
// Must contain a period and not be just a period
if (!word.includes('.') || word === '.') {
return undefined;
}
const range = new vscode.Range(new vscode.Position(position.line, start), new vscode.Position(position.line, end));
return { word, range };
}
/**
* Check if a file exists in the same directory as the document
*/
function find_file_in_same_directory(document, filename) {
const doc_dir = path.dirname(document.uri.fsPath);
const file_path = path.join(doc_dir, filename);
if (fs.existsSync(file_path)) {
return file_path;
}
return undefined;
}
/**
* Provides semantic tokens for file references in comments (light blue like class properties)
*/
class CommentFileReferenceSemanticTokensProvider {
async provideDocumentSemanticTokens(document) {
const tokens_builder = new vscode.SemanticTokensBuilder();
// Only for JavaScript/TypeScript files
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return tokens_builder.build();
}
const text = document.getText();
// Find all words with periods
const regex = /\b[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g;
let match;
while ((match = regex.exec(text)) !== null) {
const start_pos = document.positionAt(match.index);
// Skip if not in a comment
if (!is_in_comment(document, start_pos)) {
continue;
}
const word = match[0];
// Check if file exists in same directory
if (find_file_in_same_directory(document, word)) {
tokens_builder.push(start_pos.line, start_pos.character, word.length, 0, // token type index for 'class' (teal)
0 // token modifiers
);
}
}
return tokens_builder.build();
}
}
exports.CommentFileReferenceSemanticTokensProvider = CommentFileReferenceSemanticTokensProvider;
/**
* Provides "Go to Definition" for file references in comments
*/
class CommentFileReferenceDefinitionProvider {
async provideDefinition(document, position, _token) {
// Only for JavaScript/TypeScript files
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return undefined;
}
// Must be in a comment
if (!is_in_comment(document, position)) {
return undefined;
}
// Get word with period
const word_info = get_word_with_period(document, position);
if (!word_info) {
return undefined;
}
// Check if file exists in same directory
const file_path = find_file_in_same_directory(document, word_info.word);
if (!file_path) {
return undefined;
}
// Return file location
const file_uri = vscode.Uri.file(file_path);
return new vscode.Location(file_uri, new vscode.Position(0, 0));
}
}
exports.CommentFileReferenceDefinitionProvider = CommentFileReferenceDefinitionProvider;
//# sourceMappingURL=comment_file_reference_provider.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"comment_file_reference_provider.js","sourceRoot":"","sources":["../src/comment_file_reference_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AACjC,2CAA6B;AAC7B,uCAAyB;AAEzB;;GAEG;AACH,SAAS,aAAa,CAAC,QAA6B,EAAE,QAAyB;IAC3E,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;IACtD,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEpC,gCAAgC;IAChC,MAAM,kBAAkB,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,kBAAkB,KAAK,CAAC,CAAC,IAAI,kBAAkB,GAAG,QAAQ,EAAE;QAC5D,OAAO,IAAI,CAAC;KACf;IAED,kCAAkC;IAClC,MAAM,eAAe,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,mBAAmB,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,MAAM,gBAAgB,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAE1D,IAAI,gBAAgB,IAAI,eAAe,KAAK,CAAC,CAAC,IAAI,mBAAmB,KAAK,CAAC,CAAC,EAAE;QAC1E,OAAO,IAAI,CAAC;KACf;IAED,kEAAkE;IAClE,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC5F,IAAI,gBAAgB,GAAG,KAAK,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,CAAC;IAEV,OAAO,CAAC,GAAG,WAAW,CAAC,MAAM,EAAE;QAC3B,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YAC1C,gBAAgB,GAAG,IAAI,CAAC;YACxB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YACjD,gBAAgB,GAAG,KAAK,CAAC;YACzB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM;YACH,CAAC,EAAE,CAAC;SACP;KACJ;IAED,OAAO,gBAAgB,CAAC;AAC5B,CAAC;AAED;;GAEG;AACH,SAAS,oBAAoB,CAAC,QAA6B,EAAE,QAAyB;IAClF,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC;IAC5B,MAAM,IAAI,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEhC,gEAAgE;IAChE,IAAI,KAAK,GAAG,IAAI,CAAC;IACjB,OAAO,KAAK,GAAG,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE;QAC7D,KAAK,EAAE,CAAC;KACX;IAED,mBAAmB;IACnB,IAAI,GAAG,GAAG,IAAI,CAAC;IACf,OAAO,GAAG,GAAG,SAAS,CAAC,MAAM,IAAI,gBAAgB,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE;QACpE,GAAG,EAAE,CAAC;KACT;IAED,MAAM,IAAI,GAAG,SAAS,CAAC,SAAS,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IAE7C,iDAAiD;IACjD,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,IAAI,KAAK,GAAG,EAAE;QACrC,OAAO,SAAS,CAAC;KACpB;IAED,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,KAAK,CAC1B,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,EACzC,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAC1C,CAAC;IAEF,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC3B,CAAC;AAED;;GAEG;AACH,SAAS,2BAA2B,CAAC,QAA6B,EAAE,QAAgB;IAChF,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAClD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAE/C,IAAI,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE;QAC1B,OAAO,SAAS,CAAC;KACpB;IAED,OAAO,SAAS,CAAC;AACrB,CAAC;AAED;;GAEG;AACH,MAAa,0CAA0C;IACnD,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,uCAAuC;QACvC,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,EAAE;YAC9E,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,8BAA8B;QAC9B,MAAM,KAAK,GAAG,qCAAqC,CAAC;QACpD,IAAI,KAAK,CAAC;QAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;YACxC,MAAM,SAAS,GAAG,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAEnD,2BAA2B;YAC3B,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE;gBACrC,SAAS;aACZ;YAED,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YAEtB,yCAAyC;YACzC,IAAI,2BAA2B,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE;gBAC7C,cAAc,CAAC,IAAI,CACf,SAAS,CAAC,IAAI,EACd,SAAS,CAAC,SAAS,EACnB,IAAI,CAAC,MAAM,EACX,CAAC,EAAE,sCAAsC;gBACzC,CAAC,CAAE,kBAAkB;iBACxB,CAAC;aACL;SACJ;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAvCD,gGAuCC;AAED;;GAEG;AACH,MAAa,sCAAsC;IAC/C,KAAK,CAAC,iBAAiB,CACnB,QAA6B,EAC7B,QAAyB,EACzB,MAAgC;QAEhC,uCAAuC;QACvC,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,EAAE;YAC9E,OAAO,SAAS,CAAC;SACpB;QAED,uBAAuB;QACvB,IAAI,CAAC,aAAa,CAAC,QAAQ,EAAE,QAAQ,CAAC,EAAE;YACpC,OAAO,SAAS,CAAC;SACpB;QAED,uBAAuB;QACvB,MAAM,SAAS,GAAG,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAC3D,IAAI,CAAC,SAAS,EAAE;YACZ,OAAO,SAAS,CAAC;SACpB;QAED,yCAAyC;QACzC,MAAM,SAAS,GAAG,2BAA2B,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI,CAAC,CAAC;QACxE,IAAI,CAAC,SAAS,EAAE;YACZ,OAAO,SAAS,CAAC;SACpB;QAED,uBAAuB;QACvB,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC5C,OAAO,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,EAAE,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACpE,CAAC;CACJ;AAhCD,wFAgCC"}

View File

@@ -35,7 +35,9 @@ const laravel_completion_provider_1 = require("./laravel_completion_provider");
const blade_spacer_1 = require("./blade_spacer");
const blade_client_1 = require("./blade_client");
const convention_method_provider_1 = require("./convention_method_provider");
const comment_file_reference_provider_1 = require("./comment_file_reference_provider");
const jqhtml_lifecycle_provider_1 = require("./jqhtml_lifecycle_provider");
const combined_semantic_provider_1 = require("./combined_semantic_provider");
const php_attribute_provider_1 = require("./php_attribute_provider");
const blade_component_provider_1 = require("./blade_component_provider");
const auto_rename_provider_1 = require("./auto_rename_provider");
@@ -47,6 +49,7 @@ const refactor_code_actions_1 = require("./refactor_code_actions");
const class_refactor_provider_1 = require("./class_refactor_provider");
const class_refactor_code_actions_1 = require("./class_refactor_code_actions");
const sort_class_methods_provider_1 = require("./sort_class_methods_provider");
const symlink_redirect_provider_1 = require("./symlink_redirect_provider");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
let folding_provider;
@@ -171,6 +174,10 @@ async function activate(context) {
// Register git diff provider
const git_diff_provider = new git_diff_provider_1.GitDiffProvider(rspade_root);
git_diff_provider.activate(context);
// Register symlink redirect provider
const symlink_redirect_provider = new symlink_redirect_provider_1.SymlinkRedirectProvider();
symlink_redirect_provider.activate(context);
console.log('Symlink redirect provider registered - system/rsx/ files will redirect to rsx/');
// Register refactor provider
const refactor_provider = new refactor_provider_1.RspadeRefactorProvider(formatting_provider);
refactor_provider.register(context);
@@ -232,7 +239,6 @@ async function activate(context) {
context.subscriptions.push(vscode.languages.registerCompletionItemProvider({ language: 'php' }, laravel_completion_provider));
console.log('Laravel completion provider registered for PHP files');
// Register convention method providers for JavaScript/TypeScript
// Note: Semantic tokens are handled by JqhtmlLifecycleSemanticTokensProvider to avoid duplicate registration
const convention_hover_provider = new convention_method_provider_1.ConventionMethodHoverProvider();
const convention_diagnostic_provider = new convention_method_provider_1.ConventionMethodDiagnosticProvider();
const convention_definition_provider = new convention_method_provider_1.ConventionMethodDefinitionProvider();
@@ -241,13 +247,20 @@ async function activate(context) {
convention_diagnostic_provider.activate(context);
console.log('Convention method providers registered for JavaScript/TypeScript');
// Register JQHTML lifecycle method providers for JavaScript/TypeScript
const jqhtml_semantic_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleSemanticTokensProvider();
const jqhtml_hover_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleHoverProvider();
const jqhtml_diagnostic_provider = new jqhtml_lifecycle_provider_1.JqhtmlLifecycleDiagnosticProvider();
context.subscriptions.push(vscode.languages.registerDocumentSemanticTokensProvider([{ language: 'javascript' }, { language: 'typescript' }], jqhtml_semantic_provider, new vscode.SemanticTokensLegend(['conventionMethod'])));
context.subscriptions.push(vscode.languages.registerHoverProvider([{ language: 'javascript' }, { language: 'typescript' }], jqhtml_hover_provider));
jqhtml_diagnostic_provider.activate(context);
console.log('JQHTML lifecycle providers registered for JavaScript/TypeScript');
// Register combined semantic tokens provider for JavaScript/TypeScript
// This includes: JQHTML lifecycle methods (orange), file references (teal), 'that' variable (blue)
const combined_semantic_provider = new combined_semantic_provider_1.CombinedSemanticTokensProvider();
context.subscriptions.push(vscode.languages.registerDocumentSemanticTokensProvider([{ language: 'javascript' }, { language: 'typescript' }], combined_semantic_provider, new vscode.SemanticTokensLegend(['conventionMethod', 'class', 'macro'])));
console.log('Combined semantic tokens provider registered (JQHTML lifecycle, file references, that variable)');
// Register comment file reference definition provider for JavaScript/TypeScript
const comment_file_reference_definition_provider = new comment_file_reference_provider_1.CommentFileReferenceDefinitionProvider();
context.subscriptions.push(vscode.languages.registerDefinitionProvider([{ language: 'javascript' }, { language: 'typescript' }], comment_file_reference_definition_provider));
console.log('Comment file reference definition provider registered for JavaScript/TypeScript');
// Register PHP attribute provider
const php_attribute_provider = new php_attribute_provider_1.PhpAttributeSemanticTokensProvider();
context.subscriptions.push(vscode.languages.registerDocumentSemanticTokensProvider([{ language: 'php' }], php_attribute_provider, new vscode.SemanticTokensLegend(['conventionMethod'])));
@@ -267,12 +280,6 @@ async function activate(context) {
definition_provider.clear_status_bar();
}));
// Register commands
context.subscriptions.push(vscode.commands.registerCommand('rspade.toggleFolding', () => {
const config = (0, config_1.get_config)();
const current = config.get('enableCodeFolding', true);
config.update('enableCodeFolding', !current, vscode.ConfigurationTarget.Workspace);
vscode.window.showInformationMessage(`RSpade code folding ${!current ? 'enabled' : 'disabled'}`);
}));
context.subscriptions.push(vscode.commands.registerCommand('rspade.formatPhpFile', async () => {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.languageId === 'php') {

File diff suppressed because one or more lines are too long

View File

@@ -401,13 +401,20 @@ class JqhtmlLifecycleDiagnosticProvider {
const method_body = text.substring(method_body_start, body_end);
// Check for violations in method body
if (method_name === 'on_create') {
// Check for this.data or that.data access
const data_access_regex = /(this|that)\.data/g;
// Check for this.data or that.data access (reading, not assignment)
// Note: on_create() now runs BEFORE first render, so assigning to this.data is valid
// We only warn on reading from this.data (accessing properties without assignment)
const data_access_regex = /(this|that)\.data\.(\w+)(\s*=)?/g;
let data_match;
while ((data_match = data_access_regex.exec(method_body)) !== null) {
// Skip if this is an assignment (has the = part in capture group 3)
if (data_match[3]) {
continue;
}
// This is a read access, not an assignment - warn about it
const violation_pos = document.positionAt(method_body_start + data_match.index);
const violation_end = document.positionAt(method_body_start + data_match.index + data_match[0].length);
diagnostics.push(new vscode.Diagnostic(new vscode.Range(violation_pos, violation_end), `'${data_match[0]}' is populated during on_load, which happens after on_create. Did you mean ${data_match[1]}.args?`, vscode.DiagnosticSeverity.Warning));
diagnostics.push(new vscode.Diagnostic(new vscode.Range(violation_pos, violation_end), `'${data_match[0]}' is being read in on_create, but this.data should only be initialized here (assignments like 'this.data.rows = []' are OK)`, vscode.DiagnosticSeverity.Warning));
}
}
if (method_name === 'on_load') {

File diff suppressed because one or more lines are too long

View File

@@ -32,6 +32,7 @@ const FRAMEWORK_ATTRIBUTES = [
'Ajax_Endpoint',
'Route',
'Auth',
'Task',
'Relationship',
'Monoprogenic',
'Instantiatable'

View File

@@ -1 +1 @@
{"version":3,"file":"php_attribute_provider.js","sourceRoot":"","sources":["../src/php_attribute_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAEjC;;GAEG;AACH,MAAM,oBAAoB,GAAG;IACzB,eAAe;IACf,OAAO;IACP,MAAM;IACN,cAAc;IACd,cAAc;IACd,gBAAgB;CACnB,CAAC;AAEF;;GAEG;AACH,MAAa,kCAAkC;IAC3C,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,IAAI,QAAQ,CAAC,UAAU,KAAK,KAAK,EAAE;YAC/B,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,iEAAiE;QACjE,KAAK,MAAM,cAAc,IAAI,oBAAoB,EAAE;YAC/C,4EAA4E;YAC5E,+DAA+D;YAC/D,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,aAAa,cAAc,kBAAkB,EAAE,GAAG,CAAC,CAAC;YAC7E,IAAI,KAAK,CAAC;YAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;gBACxC,gEAAgE;gBAChE,MAAM,UAAU,GAAG,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC5D,MAAM,QAAQ,GAAG,QAAQ,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;gBAEjD,cAAc,CAAC,IAAI,CACf,QAAQ,CAAC,IAAI,EACb,QAAQ,CAAC,SAAS,EAClB,cAAc,CAAC,MAAM,EACrB,CAAC,EAAE,0CAA0C;gBAC7C,CAAC,CAAE,kBAAkB;iBACxB,CAAC;aACL;SACJ;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAlCD,gFAkCC"}
{"version":3,"file":"php_attribute_provider.js","sourceRoot":"","sources":["../src/php_attribute_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAEjC;;GAEG;AACH,MAAM,oBAAoB,GAAG;IACzB,eAAe;IACf,OAAO;IACP,MAAM;IACN,MAAM;IACN,cAAc;IACd,cAAc;IACd,gBAAgB;CACnB,CAAC;AAEF;;GAEG;AACH,MAAa,kCAAkC;IAC3C,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,IAAI,QAAQ,CAAC,UAAU,KAAK,KAAK,EAAE;YAC/B,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,iEAAiE;QACjE,KAAK,MAAM,cAAc,IAAI,oBAAoB,EAAE;YAC/C,4EAA4E;YAC5E,+DAA+D;YAC/D,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,aAAa,cAAc,kBAAkB,EAAE,GAAG,CAAC,CAAC;YAC7E,IAAI,KAAK,CAAC;YAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;gBACxC,gEAAgE;gBAChE,MAAM,UAAU,GAAG,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC5D,MAAM,QAAQ,GAAG,QAAQ,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;gBAEjD,cAAc,CAAC,IAAI,CACf,QAAQ,CAAC,IAAI,EACb,QAAQ,CAAC,SAAS,EAClB,cAAc,CAAC,MAAM,EACrB,CAAC,EAAE,0CAA0C;gBAC7C,CAAC,CAAE,kBAAkB;iBACxB,CAAC;aACL;SACJ;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAlCD,gFAkCC"}

View File

@@ -174,14 +174,11 @@ class RspadeRefactorProvider {
this.output_channel.appendLine('\n=== Refactor Complete ===');
// Check if refactor was successful
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files
// Wait for filesystem changes to propagate then reload files
// Note: Panel is kept open so developer can see results and warnings
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
setTimeout(async () => {
await this.reload_all_open_files();
}, 500);
}, 3500);
await this.reload_all_open_files();
}, 500);
vscode.window.showInformationMessage(`Successfully refactored ${method_info.class_name}::${method_info.method_name} to ${new_method_name}`);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,120 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SymlinkRedirectProvider = void 0;
const vscode = __importStar(require("vscode"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
/**
* Redirects files opened from system/rsx/ symlink to their real location in rsx/
*
* The system/rsx/ directory is a symlink to rsx/ for framework compatibility,
* but users should always edit files in the real rsx/ directory.
*/
class SymlinkRedirectProvider {
constructor() {
this.disposables = [];
}
activate(context) {
// Watch for document opens and switches
this.disposables.push(vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
this.check_and_redirect(editor.document);
}
}));
// Also check when window first opens or tabs change
this.disposables.push(vscode.workspace.onDidOpenTextDocument(document => {
this.check_and_redirect(document);
}));
// Check currently active editor immediately
if (vscode.window.activeTextEditor) {
this.check_and_redirect(vscode.window.activeTextEditor.document);
}
console.log('[RSpade] Symlink redirect provider activated');
}
async check_and_redirect(document) {
const file_path = document.uri.fsPath;
// Check if this is a file in system/rsx/
if (!file_path.includes('/system/rsx/') && !file_path.includes('\\system\\rsx\\')) {
return; // Not in system/rsx/, no action needed
}
// Find the workspace folder
const workspace_folder = vscode.workspace.getWorkspaceFolder(document.uri);
if (!workspace_folder) {
return;
}
const workspace_root = workspace_folder.uri.fsPath;
// Extract the path after system/rsx/
const system_rsx_pattern = /[\/\\]system[\/\\]rsx[\/\\](.*)/;
const match = file_path.match(system_rsx_pattern);
if (!match) {
return; // Pattern doesn't match
}
const relative_path = match[1];
const real_file = path.join(workspace_root, 'rsx', relative_path);
// Check if the real file exists
if (!fs.existsSync(real_file)) {
// Real file doesn't exist, this might be a framework file or something else
return;
}
console.log(`[RSpade] Redirecting from system/rsx/ symlink to real file:`);
console.log(` Symlink: ${file_path}`);
console.log(` Real: ${real_file}`);
// Check if the symlink version is pinned
const is_pinned = document.uri.scheme === 'file' &&
vscode.window.tabGroups.activeTabGroup.activeTab?.isPinned;
// If pinned, unpin it first
if (is_pinned) {
await vscode.commands.executeCommand('workbench.action.unpinEditor');
}
// Open the real file
const real_uri = vscode.Uri.file(real_file);
const real_document = await vscode.workspace.openTextDocument(real_uri);
await vscode.window.showTextDocument(real_document);
// If the original was pinned, pin the new one
if (is_pinned) {
await vscode.commands.executeCommand('workbench.action.pinEditor');
}
// Close the symlink version (now in background)
// Find the tab with the symlink path and close it
const tab_groups = vscode.window.tabGroups.all;
for (const group of tab_groups) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputText &&
tab.input.uri.fsPath === file_path) {
await vscode.window.tabGroups.close(tab);
break;
}
}
}
// Show brief notification
vscode.window.setStatusBarMessage('Redirected from system/rsx/ to rsx/', 2000);
}
dispose() {
this.disposables.forEach(d => d.dispose());
}
}
exports.SymlinkRedirectProvider = SymlinkRedirectProvider;
//# sourceMappingURL=symlink_redirect_provider.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"symlink_redirect_provider.js","sourceRoot":"","sources":["../src/symlink_redirect_provider.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,113 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ThatVariableSemanticTokensProvider = void 0;
const vscode = __importStar(require("vscode"));
/**
* Check if position is inside a string
*/
function is_in_string(document, position) {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Simple check: count quotes before position
let single_quotes = 0;
let double_quotes = 0;
for (let i = 0; i < char_pos; i++) {
if (line_text[i] === "'" && (i === 0 || line_text[i - 1] !== '\\')) {
single_quotes++;
}
else if (line_text[i] === '"' && (i === 0 || line_text[i - 1] !== '\\')) {
double_quotes++;
}
}
// If odd number of quotes, we're inside a string
return (single_quotes % 2 === 1) || (double_quotes % 2 === 1);
}
/**
* Check if position is inside a comment
*/
function is_in_comment(document, position) {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Check for single-line comment
const single_comment_idx = line_text.indexOf('//');
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
return true;
}
// Check for multi-line comment by looking at text before position
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
let in_block_comment = false;
let i = 0;
while (i < text_before.length) {
if (text_before.substring(i, i + 2) === '/*') {
in_block_comment = true;
i += 2;
}
else if (text_before.substring(i, i + 2) === '*/') {
in_block_comment = false;
i += 2;
}
else {
i++;
}
}
return in_block_comment;
}
/**
* Provides semantic tokens for 'that' variable (dark blue like 'this' keyword)
*/
class ThatVariableSemanticTokensProvider {
async provideDocumentSemanticTokens(document) {
const tokens_builder = new vscode.SemanticTokensBuilder();
// Only for JavaScript/TypeScript files
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return tokens_builder.build();
}
const text = document.getText();
// Find all occurrences of 'that' as a standalone word
const regex = /\bthat\b/g;
let match;
while ((match = regex.exec(text)) !== null) {
const start_pos = document.positionAt(match.index);
// Skip if inside a string
if (is_in_string(document, start_pos)) {
continue;
}
// Skip if inside a comment
if (is_in_comment(document, start_pos)) {
continue;
}
// Highlight 'that' with same blue color as 'this' keyword
// Using 'variable' type with 'defaultLibrary' modifier to match language variable color
tokens_builder.push(start_pos.line, start_pos.character, 4, // length of 'that'
0, // token type index for 'variable'
1 // token modifiers: bit 0 = defaultLibrary
);
}
return tokens_builder.build();
}
}
exports.ThatVariableSemanticTokensProvider = ThatVariableSemanticTokensProvider;
//# sourceMappingURL=that_variable_provider.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"that_variable_provider.js","sourceRoot":"","sources":["../src/that_variable_provider.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAEjC;;GAEG;AACH,SAAS,YAAY,CAAC,QAA6B,EAAE,QAAyB;IAC1E,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;IACtD,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEpC,6CAA6C;IAC7C,IAAI,aAAa,GAAG,CAAC,CAAC;IACtB,IAAI,aAAa,GAAG,CAAC,CAAC;IAEtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,EAAE,CAAC,EAAE,EAAE;QAC/B,IAAI,SAAS,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,EAAE;YAChE,aAAa,EAAE,CAAC;SACnB;aAAM,IAAI,SAAS,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,EAAE;YACvE,aAAa,EAAE,CAAC;SACnB;KACJ;IAED,iDAAiD;IACjD,OAAO,CAAC,aAAa,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,aAAa,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;AAClE,CAAC;AAED;;GAEG;AACH,SAAS,aAAa,CAAC,QAA6B,EAAE,QAAyB;IAC3E,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;IACtD,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,CAAC;IAEpC,gCAAgC;IAChC,MAAM,kBAAkB,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,kBAAkB,KAAK,CAAC,CAAC,IAAI,kBAAkB,GAAG,QAAQ,EAAE;QAC5D,OAAO,IAAI,CAAC;KACf;IAED,kEAAkE;IAClE,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC5F,IAAI,gBAAgB,GAAG,KAAK,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,CAAC;IAEV,OAAO,CAAC,GAAG,WAAW,CAAC,MAAM,EAAE;QAC3B,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YAC1C,gBAAgB,GAAG,IAAI,CAAC;YACxB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM,IAAI,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE;YACjD,gBAAgB,GAAG,KAAK,CAAC;YACzB,CAAC,IAAI,CAAC,CAAC;SACV;aAAM;YACH,CAAC,EAAE,CAAC;SACP;KACJ;IAED,OAAO,gBAAgB,CAAC;AAC5B,CAAC;AAED;;GAEG;AACH,MAAa,kCAAkC;IAC3C,KAAK,CAAC,6BAA6B,CAAC,QAA6B;QAC7D,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,qBAAqB,EAAE,CAAC;QAE1D,uCAAuC;QACvC,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,IAAI,QAAQ,CAAC,UAAU,KAAK,YAAY,EAAE;YAC9E,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;SACjC;QAED,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAC;QAEhC,sDAAsD;QACtD,MAAM,KAAK,GAAG,WAAW,CAAC;QAC1B,IAAI,KAAK,CAAC;QAEV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;YACxC,MAAM,SAAS,GAAG,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAEnD,0BAA0B;YAC1B,IAAI,YAAY,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE;gBACnC,SAAS;aACZ;YAED,2BAA2B;YAC3B,IAAI,aAAa,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE;gBACpC,SAAS;aACZ;YAED,0DAA0D;YAC1D,wFAAwF;YACxF,cAAc,CAAC,IAAI,CACf,SAAS,CAAC,IAAI,EACd,SAAS,CAAC,SAAS,EACnB,CAAC,EAAE,mBAAmB;YACtB,CAAC,EAAE,kCAAkC;YACrC,CAAC,CAAE,0CAA0C;aAChD,CAAC;SACL;QAED,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACJ;AAzCD,gFAyCC"}

View File

@@ -2,7 +2,7 @@
"name": "rspade-framework",
"displayName": "RSpade Framework Support",
"description": "VS Code extension for RSpade framework with code folding, formatting, and namespace management",
"version": "0.1.186",
"version": "0.1.212",
"publisher": "rspade",
"engines": {
"vscode": "^1.74.0"
@@ -53,10 +53,6 @@
}
},
"commands": [
{
"command": "rspade.toggleFolding",
"title": "RSpade: Toggle LLMDIRECTIVE Folding"
},
{
"command": "rspade.formatPhpFile",
"title": "RSpade: Format PHP File"

View File

@@ -81,7 +81,6 @@ export class RspadeClassRefactorProvider {
if (!new_class_name) {
this.output_channel.appendLine('Refactor cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
@@ -96,7 +95,6 @@ export class RspadeClassRefactorProvider {
if (confirmation !== 'Rename') {
this.output_channel.appendLine('Global rename cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
@@ -144,27 +142,23 @@ export class RspadeClassRefactorProvider {
// Check if refactor was successful
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files and auto-rename
// Wait for filesystem changes to propagate then reload files and auto-rename
// Note: Panel is kept open so developer can see results and warnings
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
await this.reload_all_open_files();
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
// Wait another 500ms then check if current file needs renaming
setTimeout(async () => {
await this.reload_all_open_files();
// Wait another 500ms then check if current file needs renaming
setTimeout(async () => {
const editor = vscode.window.activeTextEditor;
if (editor) {
const file_path = editor.document.uri.fsPath;
// Only auto-rename if file is in ./rsx
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
await this.auto_rename_provider.check_and_rename(editor.document);
}
const editor = vscode.window.activeTextEditor;
if (editor) {
const file_path = editor.document.uri.fsPath;
// Only auto-rename if file is in ./rsx
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
await this.auto_rename_provider.check_and_rename(editor.document);
}
}, 500);
}
}, 500);
}, 3500);
}, 500);
vscode.window.showInformationMessage(
`Successfully refactored ${class_info.class_name} to ${new_class_name}`

View File

@@ -0,0 +1,118 @@
import * as vscode from 'vscode';
import { JqhtmlLifecycleSemanticTokensProvider } from './jqhtml_lifecycle_provider';
import { CommentFileReferenceSemanticTokensProvider } from './comment_file_reference_provider';
import { ThatVariableSemanticTokensProvider } from './that_variable_provider';
interface DecodedToken {
line: number;
char: number;
length: number;
type: number;
modifiers: number;
}
/**
* Combined semantic tokens provider that merges tokens from multiple providers
*
* VS Code only allows one SemanticTokensLegend per language, so we need to
* combine all our providers into one to avoid conflicts.
*/
export class CombinedSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
private jqhtml_provider: JqhtmlLifecycleSemanticTokensProvider;
private file_ref_provider: CommentFileReferenceSemanticTokensProvider;
private that_provider: ThatVariableSemanticTokensProvider;
constructor() {
this.jqhtml_provider = new JqhtmlLifecycleSemanticTokensProvider();
this.file_ref_provider = new CommentFileReferenceSemanticTokensProvider();
this.that_provider = new ThatVariableSemanticTokensProvider();
}
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
// Get tokens from all providers
const jqhtml_tokens = await this.jqhtml_provider.provideDocumentSemanticTokens(document);
const file_ref_tokens = await this.file_ref_provider.provideDocumentSemanticTokens(document);
const that_tokens = await this.that_provider.provideDocumentSemanticTokens(document);
// Decode all tokens to absolute positions
const decoded_tokens: DecodedToken[] = [];
// Decode JQHTML tokens (type 0 = conventionMethod, orange)
this.decode_tokens(jqhtml_tokens.data, 0, decoded_tokens);
// Decode file reference tokens (type 1 = class, teal)
this.decode_tokens(file_ref_tokens.data, 1, decoded_tokens);
// Decode 'that' tokens (type 2 = macro, dark blue #569CD6 like 'this')
this.decode_tokens(that_tokens.data, 2, decoded_tokens);
// Sort tokens by line, then by character
decoded_tokens.sort((a, b) => {
if (a.line !== b.line) {
return a.line - b.line;
}
return a.char - b.char;
});
// Re-encode tokens with delta encoding
const builder = new vscode.SemanticTokensBuilder();
for (const token of decoded_tokens) {
builder.push(token.line, token.char, token.length, token.type, token.modifiers);
}
return builder.build();
}
private decode_tokens(data: Uint32Array, new_type: number, output: DecodedToken[]): void {
let current_line = 0;
let current_char = 0;
for (let i = 0; i < data.length; i += 5) {
const delta_line = data[i];
const delta_char = data[i + 1];
const length = data[i + 2];
const modifiers = data[i + 4];
if (delta_line > 0) {
current_line += delta_line;
current_char = delta_char;
} else {
current_char += delta_char;
}
output.push({
line: current_line,
char: current_char,
length: length,
type: new_type,
modifiers: modifiers
});
}
}
private decode_tokens_with_modifier(data: Uint32Array, new_type: number, new_modifier: number, output: DecodedToken[]): void {
let current_line = 0;
let current_char = 0;
for (let i = 0; i < data.length; i += 5) {
const delta_line = data[i];
const delta_char = data[i + 1];
const length = data[i + 2];
if (delta_line > 0) {
current_line += delta_line;
current_char = delta_char;
} else {
current_char += delta_char;
}
output.push({
line: current_line,
char: current_char,
length: length,
type: new_type,
modifiers: new_modifier
});
}
}
}

View File

@@ -0,0 +1,175 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
/**
* Check if position is inside a comment
*/
function is_in_comment(document: vscode.TextDocument, position: vscode.Position): boolean {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Check for single-line comment
const single_comment_idx = line_text.indexOf('//');
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
return true;
}
// Check for block comment markers
const doc_comment_idx = line_text.indexOf('/*');
const doc_comment_end_idx = line_text.indexOf('*/');
const asterisk_comment = line_text.trim().startsWith('*');
if (asterisk_comment || doc_comment_idx !== -1 || doc_comment_end_idx !== -1) {
return true;
}
// Check for multi-line comment by looking at text before position
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
let in_block_comment = false;
let i = 0;
while (i < text_before.length) {
if (text_before.substring(i, i + 2) === '/*') {
in_block_comment = true;
i += 2;
} else if (text_before.substring(i, i + 2) === '*/') {
in_block_comment = false;
i += 2;
} else {
i++;
}
}
return in_block_comment;
}
/**
* Get the word at position, including periods
*/
function get_word_with_period(document: vscode.TextDocument, position: vscode.Position): { word: string, range: vscode.Range } | undefined {
const line = document.lineAt(position.line);
const line_text = line.text;
const char = position.character;
// Find start of word (alphanumeric, underscore, period, hyphen)
let start = char;
while (start > 0 && /[a-zA-Z0-9_.-]/.test(line_text[start - 1])) {
start--;
}
// Find end of word
let end = char;
while (end < line_text.length && /[a-zA-Z0-9_.-]/.test(line_text[end])) {
end++;
}
const word = line_text.substring(start, end);
// Must contain a period and not be just a period
if (!word.includes('.') || word === '.') {
return undefined;
}
const range = new vscode.Range(
new vscode.Position(position.line, start),
new vscode.Position(position.line, end)
);
return { word, range };
}
/**
* Check if a file exists in the same directory as the document
*/
function find_file_in_same_directory(document: vscode.TextDocument, filename: string): string | undefined {
const doc_dir = path.dirname(document.uri.fsPath);
const file_path = path.join(doc_dir, filename);
if (fs.existsSync(file_path)) {
return file_path;
}
return undefined;
}
/**
* Provides semantic tokens for file references in comments (light blue like class properties)
*/
export class CommentFileReferenceSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
const tokens_builder = new vscode.SemanticTokensBuilder();
// Only for JavaScript/TypeScript files
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return tokens_builder.build();
}
const text = document.getText();
// Find all words with periods
const regex = /\b[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g;
let match;
while ((match = regex.exec(text)) !== null) {
const start_pos = document.positionAt(match.index);
// Skip if not in a comment
if (!is_in_comment(document, start_pos)) {
continue;
}
const word = match[0];
// Check if file exists in same directory
if (find_file_in_same_directory(document, word)) {
tokens_builder.push(
start_pos.line,
start_pos.character,
word.length,
0, // token type index for 'class' (teal)
0 // token modifiers
);
}
}
return tokens_builder.build();
}
}
/**
* Provides "Go to Definition" for file references in comments
*/
export class CommentFileReferenceDefinitionProvider implements vscode.DefinitionProvider {
async provideDefinition(
document: vscode.TextDocument,
position: vscode.Position,
_token: vscode.CancellationToken
): Promise<vscode.Definition | undefined> {
// Only for JavaScript/TypeScript files
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return undefined;
}
// Must be in a comment
if (!is_in_comment(document, position)) {
return undefined;
}
// Get word with period
const word_info = get_word_with_period(document, position);
if (!word_info) {
return undefined;
}
// Check if file exists in same directory
const file_path = find_file_in_same_directory(document, word_info.word);
if (!file_path) {
return undefined;
}
// Return file location
const file_uri = vscode.Uri.file(file_path);
return new vscode.Location(file_uri, new vscode.Position(0, 0));
}
}

View File

@@ -9,8 +9,10 @@ import { get_config } from './config';
import { LaravelCompletionProvider } from './laravel_completion_provider';
import { blade_spacer } from './blade_spacer';
import { init_blade_language_config } from './blade_client';
import { ConventionMethodSemanticTokensProvider, ConventionMethodHoverProvider, ConventionMethodDiagnosticProvider, ConventionMethodDefinitionProvider } from './convention_method_provider';
import { JqhtmlLifecycleSemanticTokensProvider, JqhtmlLifecycleHoverProvider, JqhtmlLifecycleDiagnosticProvider } from './jqhtml_lifecycle_provider';
import { ConventionMethodHoverProvider, ConventionMethodDiagnosticProvider, ConventionMethodDefinitionProvider } from './convention_method_provider';
import { CommentFileReferenceDefinitionProvider } from './comment_file_reference_provider';
import { JqhtmlLifecycleHoverProvider, JqhtmlLifecycleDiagnosticProvider } from './jqhtml_lifecycle_provider';
import { CombinedSemanticTokensProvider } from './combined_semantic_provider';
import { PhpAttributeSemanticTokensProvider } from './php_attribute_provider';
import { BladeComponentSemanticTokensProvider } from './blade_component_provider';
import { AutoRenameProvider } from './auto_rename_provider';
@@ -22,6 +24,7 @@ import { RspadeRefactorCodeActionsProvider } from './refactor_code_actions';
import { RspadeClassRefactorProvider } from './class_refactor_provider';
import { RspadeClassRefactorCodeActionsProvider } from './class_refactor_code_actions';
import { RspadeSortClassMethodsProvider } from './sort_class_methods_provider';
import { SymlinkRedirectProvider } from './symlink_redirect_provider';
import * as fs from 'fs';
import * as path from 'path';
@@ -176,6 +179,11 @@ export async function activate(context: vscode.ExtensionContext) {
const git_diff_provider = new GitDiffProvider(rspade_root);
git_diff_provider.activate(context);
// Register symlink redirect provider
const symlink_redirect_provider = new SymlinkRedirectProvider();
symlink_redirect_provider.activate(context);
console.log('Symlink redirect provider registered - system/rsx/ files will redirect to rsx/');
// Register refactor provider
const refactor_provider = new RspadeRefactorProvider(formatting_provider);
refactor_provider.register(context);
@@ -288,7 +296,6 @@ export async function activate(context: vscode.ExtensionContext) {
console.log('Laravel completion provider registered for PHP files');
// Register convention method providers for JavaScript/TypeScript
// Note: Semantic tokens are handled by JqhtmlLifecycleSemanticTokensProvider to avoid duplicate registration
const convention_hover_provider = new ConventionMethodHoverProvider();
const convention_diagnostic_provider = new ConventionMethodDiagnosticProvider();
const convention_definition_provider = new ConventionMethodDefinitionProvider();
@@ -312,18 +319,9 @@ export async function activate(context: vscode.ExtensionContext) {
console.log('Convention method providers registered for JavaScript/TypeScript');
// Register JQHTML lifecycle method providers for JavaScript/TypeScript
const jqhtml_semantic_provider = new JqhtmlLifecycleSemanticTokensProvider();
const jqhtml_hover_provider = new JqhtmlLifecycleHoverProvider();
const jqhtml_diagnostic_provider = new JqhtmlLifecycleDiagnosticProvider();
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
jqhtml_semantic_provider,
new vscode.SemanticTokensLegend(['conventionMethod'])
)
);
context.subscriptions.push(
vscode.languages.registerHoverProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
@@ -335,6 +333,32 @@ export async function activate(context: vscode.ExtensionContext) {
console.log('JQHTML lifecycle providers registered for JavaScript/TypeScript');
// Register combined semantic tokens provider for JavaScript/TypeScript
// This includes: JQHTML lifecycle methods (orange), file references (teal), 'that' variable (blue)
const combined_semantic_provider = new CombinedSemanticTokensProvider();
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
combined_semantic_provider,
new vscode.SemanticTokensLegend(['conventionMethod', 'class', 'macro'])
)
);
console.log('Combined semantic tokens provider registered (JQHTML lifecycle, file references, that variable)');
// Register comment file reference definition provider for JavaScript/TypeScript
const comment_file_reference_definition_provider = new CommentFileReferenceDefinitionProvider();
context.subscriptions.push(
vscode.languages.registerDefinitionProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
comment_file_reference_definition_provider
)
);
console.log('Comment file reference definition provider registered for JavaScript/TypeScript');
// Register PHP attribute provider
const php_attribute_provider = new PhpAttributeSemanticTokensProvider();
@@ -376,15 +400,6 @@ export async function activate(context: vscode.ExtensionContext) {
);
// Register commands
context.subscriptions.push(
vscode.commands.registerCommand('rspade.toggleFolding', () => {
const config = get_config();
const current = config.get<boolean>('enableCodeFolding', true);
config.update('enableCodeFolding', !current, vscode.ConfigurationTarget.Workspace);
vscode.window.showInformationMessage(`RSpade code folding ${!current ? 'enabled' : 'disabled'}`);
})
);
context.subscriptions.push(
vscode.commands.registerCommand('rspade.formatPhpFile', async () => {
const editor = vscode.window.activeTextEditor;

View File

@@ -477,18 +477,26 @@ export class JqhtmlLifecycleDiagnosticProvider {
// Check for violations in method body
if (method_name === 'on_create') {
// Check for this.data or that.data access
const data_access_regex = /(this|that)\.data/g;
// Check for this.data or that.data access (reading, not assignment)
// Note: on_create() now runs BEFORE first render, so assigning to this.data is valid
// We only warn on reading from this.data (accessing properties without assignment)
const data_access_regex = /(this|that)\.data\.(\w+)(\s*=)?/g;
let data_match;
while ((data_match = data_access_regex.exec(method_body)) !== null) {
// Skip if this is an assignment (has the = part in capture group 3)
if (data_match[3]) {
continue;
}
// This is a read access, not an assignment - warn about it
const violation_pos = document.positionAt(method_body_start + data_match.index);
const violation_end = document.positionAt(method_body_start + data_match.index + data_match[0].length);
diagnostics.push(
new vscode.Diagnostic(
new vscode.Range(violation_pos, violation_end),
`'${data_match[0]}' is populated during on_load, which happens after on_create. Did you mean ${data_match[1]}.args?`,
`'${data_match[0]}' is being read in on_create, but this.data should only be initialized here (assignments like 'this.data.rows = []' are OK)`,
vscode.DiagnosticSeverity.Warning
)
);

View File

@@ -7,6 +7,7 @@ const FRAMEWORK_ATTRIBUTES = [
'Ajax_Endpoint',
'Route',
'Auth',
'Task',
'Relationship',
'Monoprogenic',
'Instantiatable'

View File

@@ -194,15 +194,11 @@ export class RspadeRefactorProvider {
// Check if refactor was successful
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files
// Wait for filesystem changes to propagate then reload files
// Note: Panel is kept open so developer can see results and warnings
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
setTimeout(async () => {
await this.reload_all_open_files();
}, 500);
}, 3500);
await this.reload_all_open_files();
}, 500);
vscode.window.showInformationMessage(
`Successfully refactored ${method_info.class_name}::${method_info.method_name} to ${new_method_name}`

View File

@@ -0,0 +1,115 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
/**
* Redirects files opened from system/rsx/ symlink to their real location in rsx/
*
* The system/rsx/ directory is a symlink to rsx/ for framework compatibility,
* but users should always edit files in the real rsx/ directory.
*/
export class SymlinkRedirectProvider {
private disposables: vscode.Disposable[] = [];
public activate(context: vscode.ExtensionContext) {
// Watch for document opens and switches
this.disposables.push(
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
this.check_and_redirect(editor.document);
}
})
);
// Also check when window first opens or tabs change
this.disposables.push(
vscode.workspace.onDidOpenTextDocument(document => {
this.check_and_redirect(document);
})
);
// Check currently active editor immediately
if (vscode.window.activeTextEditor) {
this.check_and_redirect(vscode.window.activeTextEditor.document);
}
console.log('[RSpade] Symlink redirect provider activated');
}
private async check_and_redirect(document: vscode.TextDocument) {
const file_path = document.uri.fsPath;
// Check if this is a file in system/rsx/
if (!file_path.includes('/system/rsx/') && !file_path.includes('\\system\\rsx\\')) {
return; // Not in system/rsx/, no action needed
}
// Find the workspace folder
const workspace_folder = vscode.workspace.getWorkspaceFolder(document.uri);
if (!workspace_folder) {
return;
}
const workspace_root = workspace_folder.uri.fsPath;
// Extract the path after system/rsx/
const system_rsx_pattern = /[\/\\]system[\/\\]rsx[\/\\](.*)/;
const match = file_path.match(system_rsx_pattern);
if (!match) {
return; // Pattern doesn't match
}
const relative_path = match[1];
const real_file = path.join(workspace_root, 'rsx', relative_path);
// Check if the real file exists
if (!fs.existsSync(real_file)) {
// Real file doesn't exist, this might be a framework file or something else
return;
}
console.log(`[RSpade] Redirecting from system/rsx/ symlink to real file:`);
console.log(` Symlink: ${file_path}`);
console.log(` Real: ${real_file}`);
// Check if the symlink version is pinned
const is_pinned = document.uri.scheme === 'file' &&
vscode.window.tabGroups.activeTabGroup.activeTab?.isPinned;
// If pinned, unpin it first
if (is_pinned) {
await vscode.commands.executeCommand('workbench.action.unpinEditor');
}
// Open the real file
const real_uri = vscode.Uri.file(real_file);
const real_document = await vscode.workspace.openTextDocument(real_uri);
await vscode.window.showTextDocument(real_document);
// If the original was pinned, pin the new one
if (is_pinned) {
await vscode.commands.executeCommand('workbench.action.pinEditor');
}
// Close the symlink version (now in background)
// Find the tab with the symlink path and close it
const tab_groups = vscode.window.tabGroups.all;
for (const group of tab_groups) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputText &&
tab.input.uri.fsPath === file_path) {
await vscode.window.tabGroups.close(tab);
break;
}
}
}
// Show brief notification
vscode.window.setStatusBarMessage('Redirected from system/rsx/ to rsx/', 2000);
}
public dispose() {
this.disposables.forEach(d => d.dispose());
}
}

View File

@@ -0,0 +1,103 @@
import * as vscode from 'vscode';
/**
* Check if position is inside a string
*/
function is_in_string(document: vscode.TextDocument, position: vscode.Position): boolean {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Simple check: count quotes before position
let single_quotes = 0;
let double_quotes = 0;
for (let i = 0; i < char_pos; i++) {
if (line_text[i] === "'" && (i === 0 || line_text[i - 1] !== '\\')) {
single_quotes++;
} else if (line_text[i] === '"' && (i === 0 || line_text[i - 1] !== '\\')) {
double_quotes++;
}
}
// If odd number of quotes, we're inside a string
return (single_quotes % 2 === 1) || (double_quotes % 2 === 1);
}
/**
* Check if position is inside a comment
*/
function is_in_comment(document: vscode.TextDocument, position: vscode.Position): boolean {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Check for single-line comment
const single_comment_idx = line_text.indexOf('//');
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
return true;
}
// Check for multi-line comment by looking at text before position
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
let in_block_comment = false;
let i = 0;
while (i < text_before.length) {
if (text_before.substring(i, i + 2) === '/*') {
in_block_comment = true;
i += 2;
} else if (text_before.substring(i, i + 2) === '*/') {
in_block_comment = false;
i += 2;
} else {
i++;
}
}
return in_block_comment;
}
/**
* Provides semantic tokens for 'that' variable (dark blue like 'this' keyword)
*/
export class ThatVariableSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
const tokens_builder = new vscode.SemanticTokensBuilder();
// Only for JavaScript/TypeScript files
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return tokens_builder.build();
}
const text = document.getText();
// Find all occurrences of 'that' as a standalone word
const regex = /\bthat\b/g;
let match;
while ((match = regex.exec(text)) !== null) {
const start_pos = document.positionAt(match.index);
// Skip if inside a string
if (is_in_string(document, start_pos)) {
continue;
}
// Skip if inside a comment
if (is_in_comment(document, start_pos)) {
continue;
}
// Highlight 'that' with same blue color as 'this' keyword
// Using 'variable' type with 'defaultLibrary' modifier to match language variable color
tokens_builder.push(
start_pos.line,
start_pos.character,
4, // length of 'that'
0, // token type index for 'variable'
1 // token modifiers: bit 0 = defaultLibrary
);
}
return tokens_builder.build();
}
}

View File

@@ -15,6 +15,8 @@
"laravel/sanctum": "^3.3",
"laravel/tinker": "^2.8",
"nikic/php-parser": "^5.6",
"sokil/php-isocodes": "^4.0",
"sokil/php-isocodes-db-i18n": "^4.0",
"spatie/ignition": "^1.15",
"spatie/laravel-ignition": "2.9.0",
"spatie/laravel-sitemap": "^7.0"

103
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3ef757224d67f26a3a2f53d339242d1f",
"content-hash": "a9f0aa22360539b35117939a2f310b66",
"packages": [
{
"name": "brick/math",
@@ -3711,6 +3711,107 @@
},
"time": "2025-06-25T14:20:11+00:00"
},
{
"name": "sokil/php-isocodes",
"version": "4.4.0",
"source": {
"type": "git",
"url": "https://github.com/sokil/php-isocodes.git",
"reference": "e8287cd6afaf2d663f63def3977c0aa92b85da25"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sokil/php-isocodes/zipball/e8287cd6afaf2d663f63def3977c0aa92b85da25",
"reference": "e8287cd6afaf2d663f63def3977c0aa92b85da25",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=7.4"
},
"require-dev": {
"ext-gettext": "*",
"infection/infection": ">=0.11.5",
"php-coveralls/php-coveralls": "^2.1",
"phpmd/phpmd": "@stable",
"phpunit/phpunit": ">=7.5.20",
"sokil/php-isocodes-db-i18n": "^4.0.0",
"squizlabs/php_codesniffer": "^3.4",
"symfony/translation": "^4.4.17|^5.2",
"vimeo/psalm": "^4.3"
},
"suggest": {
"ext-gettext": "Required for gettext translation driver",
"phpbench/phpbench": "Required to run benchmarks",
"sokil/php-isocodes-db-i18n": "If frequent database updates is not necessary, and database with localization is required.",
"sokil/php-isocodes-db-only": "If frequent database updates is not necessary, and only database without localization is required.",
"symfony/translation": "Translation driver by Symfont project"
},
"type": "library",
"autoload": {
"psr-4": {
"Sokil\\IsoCodes\\": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dmytro Sokil",
"email": "dmytro.sokil@gmail.com"
}
],
"description": "ISO country, subdivision, language, currency and script definitions and their translations. Based on pythons pycountry and Debian's iso-codes.",
"support": {
"issues": "https://github.com/sokil/php-isocodes/issues",
"source": "https://github.com/sokil/php-isocodes/tree/4.4.0"
},
"time": "2025-09-16T18:13:24+00:00"
},
{
"name": "sokil/php-isocodes-db-i18n",
"version": "4.0.27",
"source": {
"type": "git",
"url": "https://github.com/sokil/php-isocodes-db-i18n.git",
"reference": "8854934be5f7fb7d7a1c93db938791c5135e2db6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sokil/php-isocodes-db-i18n/zipball/8854934be5f7fb7d7a1c93db938791c5135e2db6",
"reference": "8854934be5f7fb7d7a1c93db938791c5135e2db6",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"sokil/php-isocodes": "^4.1.1"
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dmytro Sokil",
"email": "dmytro.sokil@gmail.com"
}
],
"description": "Database and internationalisation filed for ISO country, subdivision, language, currency and script definitions and their translations. Based on pythons pycountry and Debian's iso-codes.",
"support": {
"issues": "https://github.com/sokil/php-isocodes-db-i18n/issues",
"source": "https://github.com/sokil/php-isocodes-db-i18n/tree/4.0.27"
},
"time": "2025-10-06T17:14:31+00:00"
},
{
"name": "spatie/backtrace",
"version": "1.8.1",

View File

@@ -89,7 +89,6 @@
*/
return [
/*
|--------------------------------------------------------------------------
| Manifest Modules
@@ -277,15 +276,14 @@ return [
// Base directories to scan (relative to base_path())
// Can be directories (will be scanned recursively) or individual files
'scan_directories' => [
'rsx', // Main RSX application directory (symlinked to ../rsx)
'app/RSpade/Core', // Core framework classes (runtime essentials)
'app/RSpade/Modules', // Manifest support modules
'app/RSpade/Integrations', // Integration modules (Jqhtml, Scss, etc.)
'app/RSpade/Bundles', // Third-party bundles
'app/RSpade/Core/Providers', // Service providers
'app/RSpade/CodeQuality', // Code quality rules and checks
'app/RSpade/Testing', // Testing framework classes
'app/RSpade/temp', // Framework developer testing directory
'rsx', // Main RSX application directory (symlinked to ../rsx)
'app/RSpade/Core', // Core framework classes (runtime essentials)
'app/RSpade/Modules', // Manifest support modules
'app/RSpade/Integrations', // Integration modules (Jqhtml, Scss, etc.)
'app/RSpade/Bundles', // Third-party bundles
'app/RSpade/CodeQuality', // Code quality rules and checks
'app/RSpade/Testing', // Testing framework classes
'app/RSpade/temp', // Framework developer testing directory
],
// Specific filenames to exclude from manifest scanning (anywhere in tree)
@@ -416,7 +414,6 @@ return [
// Note: Private fields (#private) use native browser support, not transpiled
],
/*
|--------------------------------------------------------------------------
| Console Debug Configuration
@@ -483,5 +480,4 @@ return [
// Playwright generation timeout in milliseconds
'generation_timeout' => env('SSR_FPC_TIMEOUT', 30000),
],
];

View File

@@ -186,6 +186,31 @@
"created_at": "2025-10-14T17:23:51+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe add_experience_id_to_sessions"
},
"2025_10_28_025035_create_projects_table.php": {
"created_at": "2025-10-28T02:50:35+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_projects_table"
},
"2025_10_28_025113_create_tasks_table.php": {
"created_at": "2025-10-28T02:51:13+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_tasks_table"
},
"2025_10_28_043559_create_clients_table.php": {
"created_at": "2025-10-28T04:35:59+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_clients_table"
},
"2025_10_28_223500_create_geographic_data_tables.php": {
"created_at": "2025-10-28T22:35:00+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe create_geographic_data_tables"
},
"2025_10_29_034934_add_soft_deletes_to_core_tables.php": {
"created_at": "2025-10-29T03:49:34+00:00",
"created_by": "root",
"command": "php artisan make:migration:safe add_soft_deletes_to_core_tables"
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* IMPORTANT: Use raw MySQL queries for clarity and auditability
* DB::statement() with raw SQL
* Schema::create() with Blueprint
*
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
*
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
* Never use unsigned - all integers should be signed
*
* Migrations must be self-contained - no Model/Service references
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE projects (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
description LONGTEXT,
client_id BIGINT NOT NULL,
client_department_id BIGINT NULL,
contact_id BIGINT NULL,
status BIGINT NOT NULL DEFAULT 1,
priority BIGINT NOT NULL DEFAULT 2,
start_date DATE NULL,
due_date DATE NULL,
completed_date DATE NULL,
budget DECIMAL(15, 2) NULL,
notes LONGTEXT,
created_by BIGINT NULL,
owner_user_id BIGINT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
INDEX idx_site_id (site_id),
INDEX idx_client_id (client_id),
INDEX idx_client_department_id (client_department_id),
INDEX idx_contact_id (contact_id),
INDEX idx_status (status),
INDEX idx_priority (priority),
INDEX idx_created_by (created_by),
INDEX idx_owner_user_id (owner_user_id),
INDEX idx_created_at (created_at),
INDEX idx_updated_at (updated_at),
INDEX idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* IMPORTANT: Use raw MySQL queries for clarity and auditability
* DB::statement() with raw SQL
* Schema::create() with Blueprint
*
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
*
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
* Never use unsigned - all integers should be signed
*
* Migrations must be self-contained - no Model/Service references
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE tasks (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
description LONGTEXT,
taskable_type VARCHAR(255) NOT NULL,
taskable_id BIGINT NOT NULL,
status BIGINT NOT NULL DEFAULT 1,
priority BIGINT NOT NULL DEFAULT 2,
due_date DATE NULL,
completed_date DATE NULL,
assigned_to_user_id BIGINT NULL,
notes LONGTEXT,
created_by BIGINT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
INDEX idx_site_id (site_id),
INDEX idx_taskable (taskable_type, taskable_id),
INDEX idx_status (status),
INDEX idx_priority (priority),
INDEX idx_assigned_to_user_id (assigned_to_user_id),
INDEX idx_created_by (created_by),
INDEX idx_created_at (created_at),
INDEX idx_updated_at (updated_at),
INDEX idx_title (title)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* IMPORTANT: Use raw MySQL queries for clarity and auditability
* DB::statement() with raw SQL
* Schema::create() with Blueprint
*
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
*
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
* Never use unsigned - all integers should be signed
*
* Migrations must be self-contained - no Model/Service references
*
* @return void
*/
public function up()
{
// Add new columns to existing clients table
DB::statement("
ALTER TABLE clients
ADD COLUMN email VARCHAR(255) NULL AFTER website,
ADD COLUMN fax VARCHAR(50) NULL AFTER phone,
ADD COLUMN address_street VARCHAR(255) NULL COMMENT 'renamed from address',
ADD COLUMN address_country VARCHAR(100) NULL DEFAULT 'USA',
ADD COLUMN industry VARCHAR(100) NULL,
ADD COLUMN company_size VARCHAR(20) NULL COMMENT '1-10, 11-50, 51-200, 201-500, 501-1000, 1000+',
ADD COLUMN established_year BIGINT NULL,
ADD COLUMN revenue_range VARCHAR(50) NULL,
ADD COLUMN facebook_url VARCHAR(255) NULL,
ADD COLUMN twitter_handle VARCHAR(100) NULL,
ADD COLUMN linkedin_url VARCHAR(255) NULL,
ADD COLUMN instagram_handle VARCHAR(100) NULL,
ADD COLUMN tags JSON NULL,
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, inactive, prospect, archived',
ADD COLUMN preferred_contact_method VARCHAR(20) NULL DEFAULT 'email' COMMENT 'email, phone, text, any',
ADD COLUMN newsletter_opt_in TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL,
ADD INDEX idx_email (email),
ADD INDEX idx_status (status),
ADD INDEX idx_deleted_at (deleted_at)
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* IMPORTANT: Use raw MySQL queries for clarity and auditability
* DB::statement() with raw SQL
* Schema::create() with Blueprint
*
* REQUIRED: ALL tables MUST have: id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
* No exceptions - every table needs this exact ID column (SIGNED for easier migrations)
*
* Integer types: Use BIGINT for all integers, TINYINT(1) for booleans only
* Never use unsigned - all integers should be signed
*
* Migrations must be self-contained - no Model/Service references
*
* @return void
*/
public function up()
{
// Countries table - ISO 3166-1 data
DB::statement("
CREATE TABLE countries (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
alpha2 CHAR(2) NOT NULL UNIQUE COMMENT 'ISO 3166-1 alpha-2 code (US, CA, GB)',
alpha3 CHAR(3) NOT NULL COMMENT 'ISO 3166-1 alpha-3 code (USA, CAN, GBR)',
`numeric` CHAR(3) NOT NULL COMMENT 'ISO 3166-1 numeric code (840, 124, 826)',
name VARCHAR(255) NOT NULL COMMENT 'Official country name',
common_name VARCHAR(255) NULL COMMENT 'Common name if different from official',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Active status - disable instead of delete to preserve FKs',
created_at TIMESTAMP NULL DEFAULT NULL,
updated_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_alpha2 (alpha2),
INDEX idx_alpha3 (alpha3),
INDEX idx_enabled (enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// Regions table - ISO 3166-2 subdivisions (states, provinces, territories, etc.)
DB::statement("
CREATE TABLE regions (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(10) NOT NULL COMMENT 'ISO 3166-2 code (US-CA, CA-ON, GB-ENG)',
country_alpha2 CHAR(2) NOT NULL COMMENT 'Foreign key to countries.alpha2',
name VARCHAR(255) NOT NULL COMMENT 'Subdivision name (California, Ontario, England)',
type VARCHAR(50) NULL COMMENT 'Type: state, province, territory, country, etc.',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Active status - disable instead of delete',
created_at TIMESTAMP NULL DEFAULT NULL,
updated_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_country (country_alpha2),
INDEX idx_code (code),
INDEX idx_enabled (enabled),
UNIQUE KEY unique_country_code (country_alpha2, code),
CONSTRAINT fk_regions_country
FOREIGN KEY (country_alpha2)
REFERENCES countries(alpha2)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// Uncomment after verifying seeder works:
// \Illuminate\Support\Facades\Artisan::call('rsx:seed:geographic-data');
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* IMPORTANT: Use raw MySQL queries for clarity and auditability
* DB::statement("ALTER TABLE core_tables ADD COLUMN new_field VARCHAR(255)")
* Schema::table() with Blueprint
*
* Migrations must be self-contained - no Model/Service references
*
* @return void
*/
public function up()
{
// Note: clients table already has soft delete columns from previous migration
// Add soft delete columns to contacts table
DB::statement("ALTER TABLE contacts ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_by");
DB::statement("ALTER TABLE contacts ADD COLUMN deleted_by BIGINT NULL DEFAULT NULL AFTER deleted_at");
DB::statement("ALTER TABLE contacts ADD INDEX idx_deleted_at (deleted_at)");
// Add soft delete columns to projects table
DB::statement("ALTER TABLE projects ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_by");
DB::statement("ALTER TABLE projects ADD COLUMN deleted_by BIGINT NULL DEFAULT NULL AFTER deleted_at");
DB::statement("ALTER TABLE projects ADD INDEX idx_deleted_at (deleted_at)");
// Add soft delete columns to tasks table
DB::statement("ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_by");
DB::statement("ALTER TABLE tasks ADD COLUMN deleted_by BIGINT NULL DEFAULT NULL AFTER deleted_at");
DB::statement("ALTER TABLE tasks ADD INDEX idx_deleted_at (deleted_at)");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -40,6 +40,24 @@ This separation ensures:
## CRITICAL RULES
### 🔴 RSpade Builds Automatically - NEVER RUN BUILD COMMANDS
**RSpade is an INTERPRETED framework** - like Python or PHP, changes are automatically detected and compiled on-the-fly. There is NO manual build step.
**ABSOLUTELY FORBIDDEN** (unless explicitly instructed):
- `npm run compile` / `npm run build` - **DO NOT EXIST**
- `bin/publish` - Creates releases for OTHER developers (not for testing YOUR changes)
- `rsx:bundle:compile` - Bundles compile automatically in dev mode
- `rsx:manifest:build` - Manifest rebuilds automatically in dev mode
- ANY command with "build", "compile", or "publish"
**How it works**:
1. Edit JS/SCSS/PHP files
2. Refresh browser
3. Changes are live (< 1 second)
**If you find yourself wanting to run build commands**: STOP. You're doing something wrong. Changes are already live.
### 🔴 Framework Updates
```bash
@@ -94,6 +112,14 @@ Classes are namespacing tools. Use static unless instances needed (models, resou
**Correct workflow**: Edit → Save → Reload browser → See changes (< 1 second)
### 🔴 Trust Code Quality Rules
Each `rsx:check` rule has remediation text that tells AI assistants exactly what to do:
- Some rules say "fix immediately"
- Some rules say "present options and wait for decision"
AI should follow the rule's guidance precisely. Rules are deliberately written and well-reasoned.
---
## NAMING CONVENTIONS
@@ -185,10 +211,12 @@ class Frontend_Controller extends Rsx_Controller_Abstract
```php
// PHP
Rsx::Route('User_Controller', 'show')->url(['id' => 123]);
Rsx::Route('User_Controller', 'show', ['id' => 123]);
Rsx::Route('User_Controller', 'show', 123); // Integer shorthand for 'id'
// JavaScript (identical)
Rsx.Route('User_Controller', 'show').url({id: 123});
Rsx.Route('User_Controller', 'show', {id: 123});
Rsx.Route('User_Controller', 'show', 123); // Integer shorthand for 'id'
```
---
@@ -286,7 +314,11 @@ For mechanical thinkers who see structure, not visuals. Write `<User_Card>` not
<button class="Save_Button Jqhtml_Component btn btn-primary">Save</button>
```
**Interpolation**: `<%= escaped %>` | `<%== unescaped %>` | `<% javascript %>`
**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<% javascript %>`
**Conditional Attributes** (v2.2.162+): Apply attributes conditionally using `<% if (condition) { %>attr="value"<% } %>`
directly in attribute context. Works with static values, interpolations, and multiple conditions per element.
Example: `<input <% if (this.args.required) { %>required="required"<% } %> />`
### 🔴 CRITICAL on_load() Rules
@@ -314,13 +346,22 @@ class User_Card extends Jqhtml_Component {
### Lifecycle
1. **render** → Template executes, `this.data = {}` (empty)
2. **on_render()**Hide uninitialized UI (sync)
3. **on_create()**Quick setup (sync)
1. **on_create()** → Setup default state BEFORE template (sync)
2. **render**Template executes with initialized state
3. **on_render()**Hide uninitialized UI (sync)
4. **on_load()** → Fetch data into `this.data` (async)
5. **on_ready()** → DOM manipulation safe (async)
**Double-render**: If `on_load()` modifies `this.data`, component renders twice (empty → populated).
**on_create() now runs first** - Initialize `this.data` properties here so templates can safely reference them:
```javascript
on_create() {
this.data.rows = []; // Prevents "not iterable" errors
this.data.loading = true; // Template can check loading state
}
```
**Double-render**: If `on_load()` modifies `this.data`, component renders twice (defaults → populated).
### Loading Pattern
@@ -372,6 +413,94 @@ For advanced topics: `php artisan rsx:man jqhtml`
---
## FORM COMPONENTS
Form components use the **vals() dual-mode pattern** for getting/setting values:
```javascript
class My_Form extends Jqhtml_Component {
vals(values) {
if (values) {
// Setter - populate form
this.$id('name').val(values.name || '');
this.$id('email').val(values.email || '');
return null;
} else {
// Getter - extract values
return {
name: this.$id('name').val(),
email: this.$id('email').val()
};
}
}
}
```
**Validation**: `Form_Utils.apply_form_errors(form.$, errors)` - Matches by `name` attribute.
---
## MODALS
**Basic dialogs**:
```javascript
await Modal.alert("File saved");
if (await Modal.confirm("Delete?")) { /* confirmed */ }
let name = await Modal.prompt("Enter name:");
```
**Form modals**:
```javascript
const result = await Modal.form({
title: "Edit User",
component: "User_Form",
component_args: {data: user},
on_submit: async (form) => {
const values = form.vals();
const response = await User_Controller.save(values);
if (response.errors) {
Form_Utils.apply_form_errors(form.$, response.errors);
return false; // Keep open
}
return response.data; // Close and return
}
});
```
**Requirements**: Form component must implement `vals()` and include `<div $id="error_container"></div>`.
Details: `php artisan rsx:man modals`
---
## JQUERY EXTENSIONS
RSpade extends jQuery with utility methods:
**Element existence**: `$('.element').exists()` instead of `.length > 0`
**Component traversal**: `this.$.shallowFind('.Widget')` - Finds child elements matching selector that don't have another element of the same class as a parent between them and the component. Prevents selecting widgets from nested child components.
```javascript
// Use case: Finding form widgets without selecting nested widgets
this.$.shallowFind('.Form_Field').each(function() {
// Only processes fields directly in this form,
// not fields in nested sub-forms
});
```
**Sibling component lookup**: `$('.element').closest_sibling('.Widget')` - Searches for elements within progressively higher ancestors. Like `.closest()` but searches within ancestors instead of matching them. Stops at body tag. Useful for component-to-component communication.
**Form validation**: `$('form').checkValidity()` instead of `$('form')[0].checkValidity()`
**Click override**: `.click()` automatically calls `e.preventDefault()`. Use `.click_allow_default()` for native behavior.
For complete details: `php artisan rsx:man jquery`
---
## MODELS & DATABASE
### No Mass Assignment
@@ -756,8 +885,9 @@ The include array auto-detects:
### Execution Order
- **First:** on_create() runs before anything else (setup state)
- **Top-down:** render, on_render (parent before children)
- **Bottom-up:** on_create, on_load, on_ready (children before parent)
- **Bottom-up:** on_load, on_ready (children before parent)
- **Parallel:** Siblings at same depth process simultaneously during on_load()
### this.args vs this.data
@@ -825,6 +955,13 @@ public static function get_user_data(Request $request, array $params = [])
}
```
**Testing Ajax endpoints**: `php artisan rsx:ajax Controller action --site-id=1 --args='{"id":1}'`
Test endpoints behind auth/site scoping or invoke RPC calls from scripts. JSON-only output.
- Default: Raw response
- `--debug`: HTTP-like wrapper
- `--show-context`: Display context before JSON
### Model Fetch Security
```php
@@ -929,6 +1066,16 @@ console_debug('AJAX', 'Request sent', url, params);
- **DuplicateCaseFilesRule** - Detect same-name different-case files (critical)
- **MassAssignmentRule** - Prohibit $fillable arrays
### Trust the Rule Text
Each rule's remediation message specifies exactly how to handle violations:
- What the problem is
- Why it matters
- How to fix it
- Whether to fix autonomously or present options
**For AI assistants**: Follow the rule's guidance precisely. Don't override with "common sense" - the rule text is authoritative and deliberately written.
## MAIN_ABSTRACT MIDDLEWARE (EXPANDED)
### Execution Order

Some files were not shown because too many files have changed in this diff Show More