Files
rspade_system/app/RSpade/Integrations/Jqhtml/JqhtmlWebpackCompiler.php
root 77b4d10af8 Refactor filename naming system and apply convention-based renames
Standardize settings file naming and relocate documentation files
Fix code quality violations from rsx:check
Reorganize user_management directory into logical subdirectories
Move Quill Bundle to core and align with Tom Select pattern
Simplify Site Settings page to focus on core site information
Complete Phase 5: Multi-tenant authentication with login flow and site selection
Add route query parameter rule and synchronize filename validation logic
Fix critical bug in UpdateNpmCommand causing missing JavaScript stubs
Implement filename convention rule and resolve VS Code auto-rename conflict
Implement js-sanitizer RPC server to eliminate 900+ Node.js process spawns
Implement RPC server architecture for JavaScript parsing
WIP: Add RPC server infrastructure for JS parsing (partial implementation)
Update jqhtml terminology from destroy to stop, fix datagrid DOM preservation
Add JQHTML-CLASS-01 rule and fix redundant class names
Improve code quality rules and resolve violations
Remove legacy fatal error format in favor of unified 'fatal' error type
Filter internal keys from window.rsxapp output
Update button styling and comprehensive form/modal documentation
Add conditional fly-in animation for modals
Fix non-deterministic bundle compilation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 19:10:02 +00:00

519 lines
16 KiB
PHP
Executable File

<?php
namespace App\RSpade\Integrations\Jqhtml;
use App\RSpade\Integrations\Jqhtml\Jqhtml_Exception_ViewException;
/**
* JqhtmlWebpackCompiler - Compiles JQHTML templates to JavaScript during bundle build
*
* This service handles the compilation of .jqhtml template files into JavaScript
* during the bundle build process. It uses the @jqhtml/parser NPM package to
* perform the compilation and caches the results based on file modification times.
*
* Features:
* - Uses @jqhtml/parser for template compilation
* - Caches compiled templates based on file mtime
* - Throws fatal errors on compilation failures (fail loud)
* - Integrates with the bundle compilation pipeline
*/
#[Instantiatable]
class JqhtmlWebpackCompiler
{
/**
* RPC server script path
*/
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Integrations/Jqhtml/resource/jqhtml-compile-server.js';
/**
* RPC server socket path
*/
protected const RPC_SOCKET = 'storage/rsx-tmp/jqhtml-compile-server.sock';
/**
* RPC server process
*/
protected static $rpc_server_process = null;
/**
* RPC request ID counter
*/
protected static $request_id = 0;
/**
* Path to jqhtml-compile binary for package validation (RPC server used for actual compilation)
*/
protected string $compiler_path;
/**
* Cache directory for compiled templates
*/
protected string $cache_dir;
/**
* Constructor
*/
public function __construct()
{
// Use official jqhtml CLI compiler from npm package
$this->compiler_path = base_path('node_modules/@jqhtml/parser/bin/jqhtml-compile');
$this->cache_dir = storage_path('rsx-tmp/jqhtml-cache');
// Ensure cache directory exists
if (!is_dir($this->cache_dir)) {
mkdir($this->cache_dir, 0755, true);
}
// Validate compiler exists - MUST exist
if (!file_exists($this->compiler_path)) {
throw new \RuntimeException(
"Official JQHTML CLI compiler not found at: {$this->compiler_path}. " .
"Run 'npm install @jqhtml/parser@^2.2.59' to install the official CLI compiler."
);
}
}
/**
* Compile a single JQHTML template file
*
* @param string $file_path Path to .jqhtml file
* @return string Compiled JavaScript code
* @throws \RuntimeException On compilation failure
*/
public function compile_file(string $file_path): string
{
if (!file_exists($file_path)) {
throw new \RuntimeException("JQHTML template not found: {$file_path}");
}
// Get file modification time for cache key
$mtime = filemtime($file_path);
$cache_key = md5($file_path) . '_' . $mtime;
$cache_file = $this->cache_dir . '/' . $cache_key . '.js';
// Check if cached version exists
if (file_exists($cache_file)) {
console_debug("JQHTML", "Using cached JQHTML template: {$file_path}");
return file_get_contents($cache_file);
}
console_debug("JQHTML", "Compiling JQHTML template: {$file_path}");
// Compile via RPC server
$compiled_js = static::_compile_via_rpc($file_path);
// Don't add any comments - they break sourcemap line offsets
// Just use the compiler output as-is
$wrapped_js = $compiled_js;
// Ensure proper newline at end
if (!str_ends_with($wrapped_js, "\n")) {
$wrapped_js .= "\n";
}
// Cache the compiled result
file_put_contents($cache_file, $wrapped_js);
// Clean up old cache files for this template
$this->cleanup_old_cache($file_path, $cache_key);
return $wrapped_js;
}
/**
* Compile multiple JQHTML template files
*
* @param array $files Array of file paths
* @return array Compiled JavaScript code keyed by file path
*/
public function compile_files(array $files): array
{
$compiled = [];
foreach ($files as $file) {
try {
$compiled[$file] = $this->compile_file($file);
} catch (\Exception $e) {
// FAIL LOUD - don't continue on error
throw new \RuntimeException(
"Failed to compile JQHTML templates: " . $e->getMessage()
);
}
}
return $compiled;
}
/**
* Extract component name from file path
*
* @param string $file_path Path to .jqhtml file
* @return string Component name
*/
protected function extract_component_name(string $file_path): string
{
// Remove base path and extension
$relative = str_replace(base_path() . '/', '', $file_path);
$relative = preg_replace('/\.jqhtml$/i', '', $relative);
// Convert path to component name (e.g., rsx/app/components/MyComponent)
// to MyComponent or components/MyComponent
$parts = explode('/', $relative);
// Use the filename as the component name
return basename($relative);
}
/**
* Clean up old cache files for a template
*
* @param string $file_path Original template path
* @param string $current_cache_key Current cache key to keep
*/
protected function cleanup_old_cache(string $file_path, string $current_cache_key): void
{
$file_hash = md5($file_path);
$pattern = $this->cache_dir . '/' . $file_hash . '_*.js';
foreach (glob($pattern) as $cache_file) {
$cache_key = basename($cache_file, '.js');
if ($cache_key !== $current_cache_key) {
unlink($cache_file);
}
}
}
/**
* Clear all cached templates
*/
public function clear_cache(): void
{
$pattern = $this->cache_dir . '/*.js';
foreach (glob($pattern) as $cache_file) {
unlink($cache_file);
}
console_debug("JQHTML", "Cleared JQHTML template cache");
}
/**
* Get cache statistics
*
* @return array Cache statistics
*/
public function get_cache_stats(): array
{
$pattern = $this->cache_dir . '/*.js';
$files = glob($pattern);
$total_size = 0;
foreach ($files as $file) {
$total_size += filesize($file);
}
return [
'cache_dir' => $this->cache_dir,
'cached_files' => count($files),
'total_size' => $total_size,
'total_size_human' => $this->format_bytes($total_size)
];
}
/**
* Format bytes to human readable
*
* @param int $bytes
* @return string
*/
protected function format_bytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Start RPC server for JQHTML compilation
* Lazy initialization - called automatically on first compile
*/
public static function start_rpc_server(): void
{
$socket_path = base_path(self::RPC_SOCKET);
$server_script = base_path(self::RPC_SERVER_SCRIPT);
if (!file_exists($server_script)) {
throw new \RuntimeException("JQHTML Compile RPC server script not found at {$server_script}");
}
// If socket exists, check if it's stale
if (file_exists($socket_path)) {
// Try to ping the existing server
if (static::ping_rpc_server()) {
// Server already running and responsive
return;
}
// Socket exists but server not responding - force cleanup
static::stop_rpc_server(force: true);
}
// Ensure socket directory exists
$socket_dir = dirname($socket_path);
if (!is_dir($socket_dir)) {
mkdir($socket_dir, 0755, true);
}
// Start RPC server
$process = new \Symfony\Component\Process\Process([
'node',
$server_script,
'--socket=' . $socket_path
]);
$process->setWorkingDirectory(base_path());
$process->setTimeout(null); // No timeout for long-running server
$process->start();
static::$rpc_server_process = $process;
// Wait for server to be ready (ping until it responds)
$max_wait_ms = 10000; // 10 seconds
$wait_interval_ms = 50;
$iterations = $max_wait_ms / $wait_interval_ms;
for ($i = 0; $i < $iterations; $i++) {
usleep($wait_interval_ms * 1000); // Convert to microseconds
if (static::ping_rpc_server()) {
// Register shutdown handler to clean up server
register_shutdown_function([self::class, 'stop_rpc_server']);
return;
}
}
// Timeout waiting for server
throw new \RuntimeException(
"JQHTML Compile RPC server failed to start within {$max_wait_ms}ms.\n" .
"Check that Node.js and @jqhtml/parser are installed."
);
}
/**
* Ping the RPC server to check if it's alive
*
* @return bool True if server responds, false otherwise
*/
public static function ping_rpc_server(): bool
{
$socket_path = base_path(self::RPC_SOCKET);
if (!file_exists($socket_path)) {
return false;
}
try {
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
if (!$socket) {
return false;
}
// Set blocking mode for reliable reads/writes
stream_set_blocking($socket, true);
static::$request_id++;
$request = json_encode([
'id' => static::$request_id,
'method' => 'ping'
]) . "\n";
fwrite($socket, $request);
$response = fgets($socket);
fclose($socket);
if (!$response) {
return false;
}
$result = json_decode($response, true);
return isset($result['result']) && $result['result'] === 'pong';
} catch (\Exception $e) {
return false;
}
}
/**
* Stop the RPC server
*
* @param bool $force If true, forcefully kill the server (used for cleanup)
*/
public static function stop_rpc_server(bool $force = false): void
{
$socket_path = base_path(self::RPC_SOCKET);
if ($force) {
// Force cleanup: send shutdown, wait briefly, then SIGTERM if needed
if (file_exists($socket_path)) {
try {
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
if ($socket) {
// Set blocking mode for reliable writes
stream_set_blocking($socket, true);
static::$request_id++;
$request = json_encode([
'id' => static::$request_id,
'method' => 'shutdown'
]) . "\n";
fwrite($socket, $request);
fclose($socket);
}
} catch (\Exception $e) {
// Ignore errors during force shutdown
}
// Wait briefly for graceful shutdown
usleep(100000); // 100ms
// Force remove socket
@unlink($socket_path);
}
// Kill process if still running
if (static::$rpc_server_process && static::$rpc_server_process->isRunning()) {
static::$rpc_server_process->stop(0, SIGTERM);
}
} else {
// Graceful shutdown: just send shutdown command
if (file_exists($socket_path)) {
try {
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
if ($socket) {
// Set blocking mode for reliable writes
stream_set_blocking($socket, true);
static::$request_id++;
$request = json_encode([
'id' => static::$request_id,
'method' => 'shutdown'
]) . "\n";
fwrite($socket, $request);
fclose($socket);
}
} catch (\Exception $e) {
// Ignore errors during graceful shutdown
}
}
}
static::$rpc_server_process = null;
}
/**
* Compile file via RPC server
*
* @param string $file_path Path to file to compile
* @return string Compiled code
*/
protected static function _compile_via_rpc(string $file_path): string
{
// Start RPC server on first use (lazy initialization)
if (static::$rpc_server_process === null) {
static::start_rpc_server();
}
$socket_path = base_path(self::RPC_SOCKET);
if (!file_exists($socket_path)) {
throw new \RuntimeException("JQHTML Compile RPC server socket not found at {$socket_path}");
}
// Connect to RPC server
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 5);
if (!$socket) {
throw new \RuntimeException("Failed to connect to JQHTML Compile RPC server: {$errstr}");
}
// Set blocking mode for reliable reads/writes
stream_set_blocking($socket, true);
// Send compile request
static::$request_id++;
$request = json_encode([
'id' => static::$request_id,
'method' => 'compile',
'files' => [
[
'path' => $file_path,
'format' => 'iife',
'sourcemap' => true
]
]
]) . "\n";
fwrite($socket, $request);
// Read response
$response = fgets($socket);
fclose($socket);
if (!$response) {
throw new \RuntimeException("JQHTML Compile RPC server returned empty response for {$file_path}");
}
$result = json_decode($response, true);
if (!$result || !isset($result['results'])) {
throw new \RuntimeException(
"JQHTML Compile RPC server returned invalid response for {$file_path}:\n" . $response
);
}
$file_result = $result['results'][$file_path] ?? null;
if (!$file_result) {
throw new \RuntimeException("JQHTML Compile RPC server did not return result for {$file_path}");
}
// Handle error response
if ($file_result['status'] === 'error' && isset($file_result['error'])) {
$error = $file_result['error'];
$message = $error['message'] ?? 'Unknown error';
$line = $error['line'] ?? null;
$column = $error['column'] ?? null;
// Throw appropriate exception type
if ($line && $column) {
throw new Jqhtml_Exception_ViewException(
"JQHTML compilation failed:\n{$message}",
$file_path,
$line,
$column
);
}
throw new \RuntimeException(
"JQHTML compilation failed for {$file_path}:\n{$message}"
);
}
// Success - return compiled code
if ($file_result['status'] === 'success' && isset($file_result['result'])) {
return $file_result['result'];
}
// Unknown response format
throw new \RuntimeException(
"JQHTML Compile RPC server returned unexpected response for {$file_path}:\n" .
json_encode($file_result, JSON_PRETTY_PRINT)
);
}
}