Fix code quality violations and enhance ROUTE-EXISTS-01 rule
Implement JQHTML function cache ID system and fix bundle compilation Implement underscore prefix for system tables Fix JS syntax linter to support decorators and grant exception to Task system SPA: Update planning docs and wishlists with remaining features SPA: Document Navigation API abandonment and future enhancements Implement SPA browser integration with History API (Phase 1) Convert contacts view page to SPA action Convert clients pages to SPA actions and document conversion procedure SPA: Merge GET parameters and update documentation Implement SPA route URL generation in JavaScript and PHP Implement SPA bootstrap controller architecture Add SPA routing manual page (rsx:man spa) Add SPA routing documentation to CLAUDE.md Phase 4 Complete: Client-side SPA routing implementation Update get_routes() consumers for unified route structure Complete SPA Phase 3: PHP-side route type detection and is_spa flag Restore unified routes structure and Manifest_Query class Refactor route indexing and add SPA infrastructure Phase 3 Complete: SPA route registration in manifest Implement SPA Phase 2: Extract router code and test decorators Rename Jqhtml_Component to Component and complete SPA foundation setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -122,7 +122,7 @@ class Dispatcher
|
||||
|
||||
console_debug('DISPATCH', 'Matched default route pattern:', $controller_name, '::', $action_name);
|
||||
|
||||
// Try to find the controller using manifest
|
||||
// First try to find as PHP controller
|
||||
try {
|
||||
$metadata = Manifest::php_get_metadata_by_class($controller_name);
|
||||
$controller_fqcn = $metadata['fqcn'];
|
||||
@@ -173,7 +173,56 @@ class Dispatcher
|
||||
return redirect($proper_url, 302);
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
console_debug('DISPATCH', 'Controller not found in manifest:', $controller_name);
|
||||
console_debug('DISPATCH', 'Not a PHP controller, checking if SPA action:', $controller_name);
|
||||
|
||||
// Not found as PHP controller - check if it's a SPA action
|
||||
try {
|
||||
$is_spa_action = Manifest::js_is_subclass_of($controller_name, 'Spa_Action');
|
||||
|
||||
if ($is_spa_action) {
|
||||
console_debug('DISPATCH', 'Found SPA action class:', $controller_name);
|
||||
|
||||
// Get the file path for this JS class
|
||||
$file_path = Manifest::js_find_class($controller_name);
|
||||
|
||||
// Get file metadata which contains decorator information
|
||||
$file_data = Manifest::get_file($file_path);
|
||||
|
||||
if (!$file_data) {
|
||||
console_debug('DISPATCH', 'SPA action metadata not found:', $controller_name);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract route pattern from @route() decorator
|
||||
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
|
||||
$route_pattern = null;
|
||||
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
|
||||
foreach ($file_data['decorators'] as $decorator) {
|
||||
if (isset($decorator[0]) && $decorator[0] === 'route') {
|
||||
if (isset($decorator[1][0])) {
|
||||
$route_pattern = $decorator[1][0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($route_pattern) {
|
||||
// Generate proper URL for the SPA action
|
||||
$params = array_merge($extra_params, $request->query->all());
|
||||
$proper_url = Rsx::Route($controller_name, $action_name, $params);
|
||||
|
||||
console_debug('DISPATCH', 'Redirecting to SPA action route:', $proper_url);
|
||||
|
||||
return redirect($proper_url, 302);
|
||||
} else {
|
||||
console_debug('DISPATCH', 'SPA action missing @route() decorator:', $controller_name);
|
||||
}
|
||||
}
|
||||
} catch (\RuntimeException $spa_e) {
|
||||
console_debug('DISPATCH', 'Not a SPA action either:', $controller_name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -246,7 +295,8 @@ class Dispatcher
|
||||
Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params));
|
||||
|
||||
// Set current controller and action in Rsx for tracking
|
||||
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params);
|
||||
$route_type = $route_match['type'] ?? 'standard';
|
||||
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type);
|
||||
|
||||
// Load and validate handler class
|
||||
static::__load_handler_class($handler_class);
|
||||
@@ -339,93 +389,78 @@ class Dispatcher
|
||||
if (empty($routes)) {
|
||||
\Log::debug('Manifest::get_routes() returned empty array');
|
||||
console_debug('DISPATCH', 'Warning: got 0 routes from Manifest::get_routes()');
|
||||
} else {
|
||||
\Log::debug('Manifest has ' . count($routes) . ' route types');
|
||||
// Log details for debugging but don't output to console
|
||||
foreach ($routes as $type => $type_routes) {
|
||||
\Log::debug("Route type '$type' has " . count($type_routes) . ' patterns');
|
||||
// Show first few patterns for debugging in logs only
|
||||
$patterns = array_slice(array_keys($type_routes), 0, 5);
|
||||
\Log::debug(' First patterns: ' . implode(', ', $patterns));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort handler types by priority
|
||||
$sorted_types = array_keys(static::$handler_priorities);
|
||||
usort($sorted_types, function ($a, $b) {
|
||||
return static::$handler_priorities[$a] - static::$handler_priorities[$b];
|
||||
});
|
||||
\Log::debug('Manifest has ' . count($routes) . ' routes');
|
||||
|
||||
// Collect all matching routes
|
||||
$matches = [];
|
||||
// Get all patterns and sort by priority
|
||||
$patterns = array_keys($routes);
|
||||
$patterns = RouteResolver::sort_by_priority($patterns);
|
||||
|
||||
// Try each handler type in priority order
|
||||
foreach ($sorted_types as $type) {
|
||||
if (!isset($routes[$type])) {
|
||||
// Try to match each pattern
|
||||
foreach ($patterns as $pattern) {
|
||||
$route = $routes[$pattern];
|
||||
|
||||
// Check if HTTP method is supported
|
||||
if (!in_array($method, $route['methods'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type_routes = $routes[$type];
|
||||
// Try to match the URL
|
||||
$params = RouteResolver::match_with_query($url, $pattern);
|
||||
|
||||
// Get all patterns for this type
|
||||
$patterns = array_keys($type_routes);
|
||||
if ($params !== false) {
|
||||
// Found a match - verify the method has the required attribute
|
||||
$class_fqcn = $route['class'];
|
||||
$method_name = $route['method'];
|
||||
|
||||
// Sort patterns by priority
|
||||
$patterns = RouteResolver::sort_by_priority($patterns);
|
||||
// Get method metadata from manifest
|
||||
$class_metadata = Manifest::php_get_metadata_by_fqcn($class_fqcn);
|
||||
$method_metadata = $class_metadata['public_static_methods'][$method_name] ?? null;
|
||||
|
||||
// Try to match each pattern
|
||||
foreach ($patterns as $pattern) {
|
||||
$route_info = $type_routes[$pattern];
|
||||
if (!$method_metadata) {
|
||||
throw new \RuntimeException(
|
||||
"Route method not found in manifest: {$class_fqcn}::{$method_name}\n" .
|
||||
"Pattern: {$pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if method is supported
|
||||
if (isset($route_info[$method])) {
|
||||
// Try to match the URL
|
||||
$params = RouteResolver::match_with_query($url, $pattern);
|
||||
// Check for Route or SPA attribute
|
||||
$attributes = $method_metadata['attributes'] ?? [];
|
||||
$has_route = false;
|
||||
|
||||
if ($params !== false) {
|
||||
// Handle new structure where each method can have multiple handlers
|
||||
$handlers = $route_info[$method];
|
||||
|
||||
// If it's not an array of handlers, convert it (backwards compatibility)
|
||||
if (!isset($handlers[0])) {
|
||||
$handlers = [$handlers];
|
||||
}
|
||||
|
||||
// Add all matching handlers
|
||||
foreach ($handlers as $handler) {
|
||||
$matches[] = [
|
||||
'type' => $type,
|
||||
'pattern' => $pattern,
|
||||
'class' => $handler['class'],
|
||||
'method' => $handler['method'],
|
||||
'params' => $params,
|
||||
'file' => $handler['file'] ?? null,
|
||||
'require' => $handler['require'] ?? [],
|
||||
];
|
||||
}
|
||||
foreach ($attributes as $attr_name => $attr_instances) {
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route' ||
|
||||
str_ends_with($attr_name, '\\SPA') || $attr_name === 'SPA') {
|
||||
$has_route = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate routes
|
||||
if (count($matches) > 1) {
|
||||
$error_msg = "Multiple routes match the request '{$method} {$url}':\n\n";
|
||||
foreach ($matches as $match) {
|
||||
$error_msg .= " - Pattern: {$match['pattern']}\n";
|
||||
$error_msg .= " Class: {$match['class']}::{$match['method']}\n";
|
||||
if (!empty($match['file'])) {
|
||||
$error_msg .= " File: {$match['file']}\n";
|
||||
if (!$has_route) {
|
||||
throw new \RuntimeException(
|
||||
"Route method {$class_fqcn}::{$method_name} is missing required #[Route] or #[SPA] attribute.\n" .
|
||||
"Pattern: {$pattern}\n" .
|
||||
"File: {$route['file']}"
|
||||
);
|
||||
}
|
||||
$error_msg .= " Type: {$match['type']}\n\n";
|
||||
}
|
||||
$error_msg .= 'Routes must be unique. Please remove duplicate route definitions.';
|
||||
|
||||
throw new RuntimeException($error_msg);
|
||||
// Return route with params
|
||||
return [
|
||||
'type' => $route['type'],
|
||||
'pattern' => $pattern,
|
||||
'class' => $route['class'],
|
||||
'method' => $route['method'],
|
||||
'params' => $params,
|
||||
'file' => $route['file'] ?? null,
|
||||
'require' => $route['require'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Return the single match or null
|
||||
return $matches[0] ?? null;
|
||||
// No match found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,8 +607,9 @@ class Dispatcher
|
||||
throw new Exception("Method not public: {$class_name}::{$method_name}");
|
||||
}
|
||||
|
||||
// Set current controller and action for tracking
|
||||
Rsx::_set_current_controller_action($class_name, $method_name, $params);
|
||||
// NOTE: Do NOT call _set_current_controller_action here - it's already been set
|
||||
// earlier in the dispatch flow with the correct route type. Calling it again
|
||||
// would overwrite the route type with null.
|
||||
|
||||
// Check if this is a controller (all methods are static)
|
||||
if (static::__is_controller($class_name)) {
|
||||
@@ -1096,7 +1132,11 @@ 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');
|
||||
$action = $redirect_to[0];
|
||||
if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') {
|
||||
$action .= '::' . $redirect_to[1];
|
||||
}
|
||||
$url = Rsx::Route($action);
|
||||
if ($message) {
|
||||
Rsx::flash_error($message);
|
||||
}
|
||||
|
||||
120
app/RSpade/Core/Dispatch/Route_ManifestSupport.php
Executable file
120
app/RSpade/Core/Dispatch/Route_ManifestSupport.php
Executable file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Dispatch;
|
||||
|
||||
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
|
||||
|
||||
/**
|
||||
* Support module for building routes index from #[Route] attributes
|
||||
* This runs after the primary manifest is built to create routes index
|
||||
*/
|
||||
class Route_ManifestSupport extends ManifestSupport_Abstract
|
||||
{
|
||||
/**
|
||||
* Get the name of this support module
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_name(): string
|
||||
{
|
||||
return 'Routes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the manifest and build routes index
|
||||
*
|
||||
* @param array &$manifest_data Reference to the manifest data array
|
||||
* @return void
|
||||
*/
|
||||
public static function process(array &$manifest_data): void
|
||||
{
|
||||
// Initialize routes key
|
||||
if (!isset($manifest_data['data']['routes'])) {
|
||||
$manifest_data['data']['routes'] = [];
|
||||
}
|
||||
|
||||
// Look for Route attributes - must check all namespaces since Route is not a real class
|
||||
// PHP attributes without an import will use the current namespace
|
||||
$files = $manifest_data['data']['files'];
|
||||
$route_classes = [];
|
||||
|
||||
foreach ($files as $file => $metadata) {
|
||||
// Check public static method attributes for any attribute ending with 'Route'
|
||||
if (isset($metadata['public_static_methods'])) {
|
||||
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
|
||||
if (isset($method_data['attributes'])) {
|
||||
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
|
||||
// Check if this is a Route attribute (ends with \Route or is just Route)
|
||||
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') {
|
||||
$route_classes[] = [
|
||||
'file' => $file,
|
||||
'class' => $metadata['class'] ?? null,
|
||||
'fqcn' => $metadata['fqcn'] ?? null,
|
||||
'method' => $method_name,
|
||||
'type' => 'method',
|
||||
'instances' => $attr_instances,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($route_classes as $item) {
|
||||
if ($item['type'] === 'method') {
|
||||
foreach ($item['instances'] as $route_args) {
|
||||
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
|
||||
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
|
||||
$name = $route_args[2] ?? ($route_args['name'] ?? null);
|
||||
|
||||
if ($pattern) {
|
||||
// Ensure pattern starts with /
|
||||
if ($pattern[0] !== '/') {
|
||||
$pattern = '/' . $pattern;
|
||||
}
|
||||
|
||||
// Type is always 'standard' for routes with #[Route] attribute
|
||||
$type = 'standard';
|
||||
|
||||
// Extract Auth attributes for this method from the file metadata
|
||||
$require_attrs = [];
|
||||
$file_metadata = $files[$item['file']] ?? null;
|
||||
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) {
|
||||
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'];
|
||||
}
|
||||
|
||||
// Check for duplicate route definition (pattern must be unique across all route types)
|
||||
if (isset($manifest_data['data']['routes'][$pattern])) {
|
||||
$existing = $manifest_data['data']['routes'][$pattern];
|
||||
$existing_type = $existing['type'];
|
||||
$existing_location = $existing_type === 'spa'
|
||||
? "SPA action {$existing['js_action_class']} in {$existing['file']}"
|
||||
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Duplicate route definition: {$pattern}\n" .
|
||||
" Already defined: {$existing_location}\n" .
|
||||
" Conflicting: {$item['fqcn']}::{$item['method']} in {$item['file']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Store route with flat structure
|
||||
$manifest_data['data']['routes'][$pattern] = [
|
||||
'methods' => array_map('strtoupper', (array) $methods),
|
||||
'type' => $type,
|
||||
'class' => $item['fqcn'] ?? $item['class'],
|
||||
'method' => $item['method'],
|
||||
'name' => $name,
|
||||
'file' => $item['file'],
|
||||
'require' => $require_attrs,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
|
||||
ksort($manifest_data['data']['routes']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user