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>
517 lines
18 KiB
PHP
Executable File
517 lines
18 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* CODING CONVENTION:
|
|
* This file follows the coding convention where variable_names and function_names
|
|
* use snake_case (underscore_wherever_possible).
|
|
*
|
|
* @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
|
|
*/
|
|
|
|
namespace App\RSpade\Core;
|
|
|
|
use RuntimeException;
|
|
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
|
|
use App\RSpade\Core\Events\Event_Registry;
|
|
use App\RSpade\Core\Manifest\Manifest;
|
|
|
|
/**
|
|
* Core RSX framework utility class
|
|
*
|
|
* Provides static utility methods for the RSX framework including
|
|
* flash messages and other core functionality.
|
|
*/
|
|
class Rsx
|
|
{
|
|
/**
|
|
* Current controller being executed
|
|
* @var string|null
|
|
*/
|
|
protected static $current_controller = null;
|
|
|
|
/**
|
|
* Current action being executed
|
|
* @var string|null
|
|
*/
|
|
protected static $current_action = null;
|
|
|
|
/**
|
|
* Current request params
|
|
* @var array|null
|
|
*/
|
|
protected static $current_params = null;
|
|
|
|
/**
|
|
* Current route type ('spa' or 'standard')
|
|
* @var string|null
|
|
*/
|
|
protected static $current_route_type = null;
|
|
|
|
/**
|
|
* Set the current controller and action being executed
|
|
*
|
|
* @param string $controller_class The controller class name
|
|
* @param string $action_method The action method name
|
|
* @param array $params Optional request params to store
|
|
* @param string|null $route_type Route type ('spa' or 'standard')
|
|
*/
|
|
public static function _set_current_controller_action($controller_class, $action_method, array $params = [], $route_type = null)
|
|
{
|
|
// Extract just the class name without namespace
|
|
$parts = explode('\\', $controller_class);
|
|
$class_name = end($parts);
|
|
|
|
static::$current_controller = $class_name;
|
|
static::$current_action = $action_method;
|
|
static::$current_params = $params;
|
|
static::$current_route_type = $route_type;
|
|
}
|
|
|
|
/**
|
|
* Get the current controller class name
|
|
*
|
|
* @return string|null The current controller class or null if not set
|
|
*/
|
|
public static function get_current_controller()
|
|
{
|
|
return static::$current_controller;
|
|
}
|
|
|
|
/**
|
|
* Get the current action method name
|
|
*
|
|
* @return string|null The current action method or null if not set
|
|
*/
|
|
public static function get_current_action()
|
|
{
|
|
return static::$current_action;
|
|
}
|
|
|
|
/**
|
|
* Get the current request params
|
|
*
|
|
* @return array|null The current request params or null if not set
|
|
*/
|
|
public static function get_current_params()
|
|
{
|
|
return static::$current_params;
|
|
}
|
|
|
|
/**
|
|
* Check if current route is a SPA route
|
|
*
|
|
* @return bool True if current route type is 'spa', false otherwise
|
|
*/
|
|
public static function is_spa()
|
|
{
|
|
return static::$current_route_type === 'spa';
|
|
}
|
|
|
|
/**
|
|
* Clear the current controller and action tracking
|
|
*/
|
|
public static function _clear_current_controller_action()
|
|
{
|
|
static::$current_controller = null;
|
|
static::$current_action = null;
|
|
static::$current_params = null;
|
|
static::$current_route_type = null;
|
|
}
|
|
|
|
// Flash alert methods have been removed - use Flash class instead:
|
|
// Flash_Alert::success($message)
|
|
// Flash_Alert::error($message)
|
|
// Flash_Alert::info($message)
|
|
// Flash_Alert::warning($message)
|
|
//
|
|
// See: /system/app/RSpade/Core/Flash/Flash.php
|
|
// See: /system/app/RSpade/Core/Flash/CLAUDE.md
|
|
|
|
/**
|
|
* Generate URL for a controller route
|
|
*
|
|
* 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 "#" to allow incremental
|
|
* development without requiring all controllers to exist.
|
|
*
|
|
* Usage examples:
|
|
* ```php
|
|
* // Controller route (defaults to 'index' method)
|
|
* $url = Rsx::Route('Frontend_Index_Controller');
|
|
* // Returns: /dashboard
|
|
*
|
|
* // Controller route with explicit method
|
|
* $url = Rsx::Route('Frontend_Client_View_Controller::view', 123);
|
|
* // Returns: /clients/view/123
|
|
*
|
|
* // SPA action route
|
|
* $url = Rsx::Route('Contacts_Index_Action');
|
|
* // Returns: /contacts
|
|
*
|
|
* // Route with integer parameter (sets 'id')
|
|
* $url = Rsx::Route('Contacts_View_Action', 123);
|
|
* // Returns: /contacts/123
|
|
*
|
|
* // Route with named parameters (array)
|
|
* $url = Rsx::Route('Contacts_View_Action', ['id' => 'C001']);
|
|
* // Returns: /contacts/C001
|
|
*
|
|
* // Route with required and query parameters
|
|
* $url = Rsx::Route('Contacts_View_Action', [
|
|
* 'id' => 'C001',
|
|
* 'tab' => 'history'
|
|
* ]);
|
|
* // Returns: /contacts/C001?tab=history
|
|
*
|
|
* // Placeholder route for scaffolding (doesn't need to exist)
|
|
* $url = Rsx::Route('Future_Feature_Controller::#index');
|
|
* // Returns: #
|
|
* ```
|
|
*
|
|
* @param string $action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
|
|
* @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/action, method doesn't exist, or lacks Route attribute
|
|
*/
|
|
public static function Route($action, $params = null)
|
|
{
|
|
// Parse action into class_name and action_name
|
|
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
|
|
if (str_contains($action, '::')) {
|
|
[$class_name, $action_name] = explode('::', $action, 2);
|
|
} else {
|
|
$class_name = $action;
|
|
$action_name = 'index';
|
|
}
|
|
|
|
// 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 placeholder
|
|
if (str_starts_with($action_name, '#')) {
|
|
return '#';
|
|
}
|
|
|
|
// Try to find the class in the manifest
|
|
try {
|
|
$metadata = Manifest::php_get_metadata_by_class($class_name);
|
|
} catch (RuntimeException $e) {
|
|
// Not found as PHP class - might be a SPA action, try that instead
|
|
return static::_try_spa_action_route($class_name, $params_array);
|
|
}
|
|
|
|
// Verify it extends Rsx_Controller_Abstract
|
|
$extends = $metadata['extends'] ?? '';
|
|
$is_controller = false;
|
|
|
|
if ($extends === 'Rsx_Controller_Abstract') {
|
|
$is_controller = true;
|
|
} else {
|
|
// Check if it extends a class that extends Rsx_Controller_Abstract
|
|
$current_class = $extends;
|
|
$max_depth = 10;
|
|
|
|
while ($current_class && $max_depth-- > 0) {
|
|
try {
|
|
$parent_metadata = Manifest::php_get_metadata_by_class($current_class);
|
|
if (($parent_metadata['extends'] ?? '') === 'Rsx_Controller_Abstract') {
|
|
$is_controller = true;
|
|
break;
|
|
}
|
|
$current_class = $parent_metadata['extends'] ?? '';
|
|
} catch (RuntimeException $e) {
|
|
// Check if parent is the abstract controller with FQCN
|
|
if ($current_class === 'Rsx_Controller_Abstract' ||
|
|
$current_class === 'App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract') {
|
|
$is_controller = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$is_controller) {
|
|
throw new Rsx_Caller_Exception("Class {$class_name} must extend Rsx_Controller_Abstract");
|
|
}
|
|
|
|
// Check if method exists and has Route attribute
|
|
if (!isset($metadata['public_static_methods'][$action_name])) {
|
|
throw new Rsx_Caller_Exception("Method {$action_name} not found in class {$class_name}");
|
|
}
|
|
|
|
$method_info = $metadata['public_static_methods'][$action_name];
|
|
|
|
// All methods in public_static_methods are guaranteed to be static
|
|
// No need to check - but we assert for safety
|
|
if (!isset($method_info['static']) || !$method_info['static']) {
|
|
shouldnt_happen("Method {$class_name}::{$action_name} in public_static_methods is not static - extraction bug");
|
|
}
|
|
|
|
// Check for Route or Ajax_Endpoint attribute
|
|
$has_route = false;
|
|
$has_ajax_endpoint = false;
|
|
$route_pattern = null;
|
|
|
|
if (isset($method_info['attributes'])) {
|
|
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
|
if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) {
|
|
$has_route = true;
|
|
// Get the route pattern from the first instance
|
|
if (!empty($attr_instances)) {
|
|
$route_args = $attr_instances[0];
|
|
$route_pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
|
|
}
|
|
break;
|
|
}
|
|
if ($attr_name === 'Ajax_Endpoint' || str_ends_with($attr_name, '\\Ajax_Endpoint')) {
|
|
$has_ajax_endpoint = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If has Ajax_Endpoint, return AJAX route URL (no param substitution)
|
|
if ($has_ajax_endpoint) {
|
|
$ajax_url = '/_ajax/' . urlencode($class_name) . '/' . urlencode($action_name);
|
|
// Add query params if provided
|
|
if (!empty($params_array)) {
|
|
$ajax_url .= '?' . http_build_query($params_array);
|
|
}
|
|
return $ajax_url;
|
|
}
|
|
|
|
if (!$has_route) {
|
|
// Not a controller method with Route/Ajax - check if it's a SPA action class
|
|
return static::_try_spa_action_route($class_name, $params_array);
|
|
}
|
|
|
|
if (!$route_pattern) {
|
|
throw new Rsx_Caller_Exception("Route attribute on {$class_name}::{$action_name} must have a pattern");
|
|
}
|
|
|
|
// Generate URL from pattern
|
|
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, $action_name);
|
|
}
|
|
|
|
/**
|
|
* Try to generate URL for a SPA action class
|
|
* Called when class lookup fails for controller - checks if it's a JavaScript SPA action
|
|
*
|
|
* @param string $class_name The class name (might be a JS SPA action)
|
|
* @param array $params_array Parameters for URL generation
|
|
* @return string The generated URL
|
|
* @throws Rsx_Caller_Exception If not a valid SPA action or route not found
|
|
*/
|
|
protected static function _try_spa_action_route(string $class_name, array $params_array): string
|
|
{
|
|
// Check if this is a JavaScript class that extends Spa_Action
|
|
try {
|
|
$is_spa_action = Manifest::js_is_subclass_of($class_name, 'Spa_Action');
|
|
} catch (\RuntimeException $e) {
|
|
// Not a JS class or not found
|
|
throw new Rsx_Caller_Exception("Class {$class_name} must extend Rsx_Controller_Abstract or Spa_Action");
|
|
}
|
|
|
|
if (!$is_spa_action) {
|
|
throw new Rsx_Caller_Exception("JavaScript class {$class_name} must extend Spa_Action to generate routes");
|
|
}
|
|
|
|
// Get the file path for this JS class
|
|
try {
|
|
$file_path = Manifest::js_find_class($class_name);
|
|
} catch (\RuntimeException $e) {
|
|
throw new Rsx_Caller_Exception("SPA action class {$class_name} not found in manifest");
|
|
}
|
|
|
|
// Get file metadata which contains decorator information
|
|
try {
|
|
$file_data = Manifest::get_file($file_path);
|
|
} catch (\RuntimeException $e) {
|
|
throw new Rsx_Caller_Exception("File metadata not found for SPA action {$class_name}");
|
|
}
|
|
|
|
// Extract route pattern from decorators
|
|
// JavaScript files have 'decorators' array in their metadata
|
|
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
|
|
$route_pattern = null;
|
|
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
|
|
foreach ($file_data['decorators'] as $decorator) {
|
|
// Decorator format: [0 => 'decorator_name', 1 => [arguments]]
|
|
if (isset($decorator[0]) && $decorator[0] === 'route') {
|
|
// First argument is the route pattern
|
|
if (isset($decorator[1][0])) {
|
|
$route_pattern = $decorator[1][0];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$route_pattern) {
|
|
throw new Rsx_Caller_Exception("SPA action {$class_name} must have @route() decorator with pattern");
|
|
}
|
|
|
|
// Generate URL from pattern using same logic as regular routes
|
|
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, '(SPA action)');
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Trigger a filter event - data passes through chain of handlers
|
|
*
|
|
* Each handler receives the result of the previous handler and returns
|
|
* the modified data. Use for transforming data through a pipeline.
|
|
*
|
|
* Example:
|
|
* ```php
|
|
* $params = Rsx::trigger_filter('file.upload.params', $params);
|
|
* ```
|
|
*
|
|
* @param string $event Event name
|
|
* @param mixed $data Data to pass through filter chain
|
|
* @return mixed Transformed data
|
|
*/
|
|
public static function trigger_filter(string $event, $data)
|
|
{
|
|
$handlers = Event_Registry::get_handlers($event);
|
|
|
|
foreach ($handlers as $handler) {
|
|
$data = $handler($data);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Trigger a gate event - first non-true return value halts execution
|
|
*
|
|
* Use for authorization, validation, and permission checks.
|
|
* First handler that returns non-true halts the chain and returns that value.
|
|
*
|
|
* Example:
|
|
* ```php
|
|
* $result = Rsx::trigger_gate('file.upload.authorize', ['request' => $request]);
|
|
* if ($result !== true) {
|
|
* return $result; // Handler returned error response
|
|
* }
|
|
* ```
|
|
*
|
|
* @param string $event Event name
|
|
* @param mixed $data Data to pass to handlers
|
|
* @return true|mixed Returns true if all handlers pass, or first non-true result
|
|
*/
|
|
public static function trigger_gate(string $event, $data)
|
|
{
|
|
$handlers = Event_Registry::get_handlers($event);
|
|
|
|
foreach ($handlers as $handler) {
|
|
$result = $handler($data);
|
|
if ($result !== true) {
|
|
return $result; // First denial/error wins
|
|
}
|
|
}
|
|
|
|
return true; // All handlers passed
|
|
}
|
|
|
|
/**
|
|
* Trigger an action event - fire all handlers, ignore return values
|
|
*
|
|
* Use for logging, notifications, and other side effects.
|
|
* All handlers are called regardless of their return values.
|
|
*
|
|
* Example:
|
|
* ```php
|
|
* Rsx::trigger_action('file.upload.complete', [
|
|
* 'attachment' => $attachment,
|
|
* 'request' => $request
|
|
* ]);
|
|
* ```
|
|
*
|
|
* @param string $event Event name
|
|
* @param mixed $data Data to pass to handlers
|
|
* @return void
|
|
*/
|
|
public static function trigger_action(string $event, $data): void
|
|
{
|
|
$handlers = Event_Registry::get_handlers($event);
|
|
|
|
foreach ($handlers as $handler) {
|
|
$handler($data);
|
|
}
|
|
}
|
|
}
|